diff --git a/cmd/server/db.go b/cmd/server/db.go index 06c9827d..97377fcb 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -20,7 +20,9 @@ import ( // routeTypeTransport covers TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3) — // the only route types that carry transport_code_1 (transport-level scope). // Per firmware/docs/packet_format.md § Route Types: -// 0 = TRANSPORT_FLOOD, 1 = FLOOD, 2 = DIRECT, 3 = TRANSPORT_DIRECT. +// +// 0 = TRANSPORT_FLOOD, 1 = FLOOD, 2 = DIRECT, 3 = TRANSPORT_DIRECT. +// // Routes 1 (FLOOD) and 2 (DIRECT) never carry a scope by protocol — they are // inherently unscoped and are counted separately in GetScopeStats (#1838). const routeTypeTransportSQL = "route_type IN (0, 3)" @@ -32,11 +34,11 @@ const routeTypeNonTransportSQL = "route_type IN (1, 2)" // DB wraps a read-only connection to the MeshCore SQLite database. type DB struct { - conn *sql.DB - path string // filesystem path to the database file - isV3 bool // v3 schema: observer_idx in observations (vs observer_id in v2) - hasResolvedPath bool // observations table has resolved_path column - hasObsRawHex bool // observations table has raw_hex column (#881) + conn *sql.DB + path string // filesystem path to the database file + isV3 bool // v3 schema: observer_idx in observations (vs observer_id in v2) + hasResolvedPath bool // observations table has resolved_path column + hasObsRawHex bool // observations table has raw_hex column (#881) hasScopeName bool // transmissions.scope_name column exists (#899) hasDefaultScope bool // nodes.default_scope column exists (#899) hasMultibyteSupCols bool // nodes/inactive_nodes have multibyte_sup/multibyte_evidence (#903) @@ -229,7 +231,7 @@ func (db *DB) scanTransmissionRow(rows *sql.Rows) map[string]interface{} { // Node represents a row from the nodes table. type Node struct { - PublicKey string `json:"public_key"` + PublicKey string `json:"public_key"` Name *string `json:"name"` Role *string `json:"role"` Lat *float64 `json:"lat"` @@ -480,20 +482,20 @@ func (db *DB) GetAllRoleCounts() map[string]int { // PacketQuery holds filter params for packet listing. type PacketQuery struct { - Limit int - Offset int - Type *int - Route *int - Observer string - Hash string - Since string - Until string - Region string - Area string // area key; filters by transmitting node's GPS position - Node string - Channel string // channel_hash filter (#812). Plain names like "#test"/"public" or "enc_" for encrypted - Order string // ASC or DESC - ExpandObservations bool // when true, include observation sub-maps in txToMap output + Limit int + Offset int + Type *int + Route *int + Observer string + Hash string + Since string + Until string + Region string + Area string // area key; filters by transmitting node's GPS position + Node string + Channel string // channel_hash filter (#812). Plain names like "#test"/"public" or "enc_" for encrypted + Order string // ASC or DESC + ExpandObservations bool // when true, include observation sub-maps in txToMap output } // PacketResult wraps paginated packet list. @@ -596,7 +598,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) { COALESCE((SELECT MAX(strftime('%%Y-%%m-%%dT%%H:%%M:%%fZ', oi.timestamp, 'unixepoch')) FROM observations oi WHERE oi.transmission_id = t.id), t.first_seen) AS latest, obs.id AS observer_id, obs.name AS observer_name, COALESCE(obs.iata, '') AS observer_iata, o.snr, o.rssi, o.path_json, - COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas` + groupedScopeCol + ` + COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.rowid = oi.observer_idx WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas`+groupedScopeCol+` FROM transmissions t LEFT JOIN observations o ON o.id = ( SELECT id FROM observations WHERE transmission_id = t.id @@ -611,7 +613,7 @@ func (db *DB) QueryGroupedPackets(q PacketQuery) (*PacketResult, error) { COALESCE((SELECT MAX(oi.timestamp) FROM observations oi WHERE oi.transmission_id = t.id), t.first_seen) AS latest, o.observer_id, o.observer_name, COALESCE(obs2.iata, '') AS observer_iata, o.snr, o.rssi, o.path_json, - COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.id = oi.observer_id WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas` + groupedScopeCol + ` + COALESCE((SELECT GROUP_CONCAT(DISTINCT obi.iata) FROM observations oi JOIN observers obi ON obi.id = oi.observer_id WHERE oi.transmission_id = t.id AND obi.iata IS NOT NULL AND obi.iata != ''), '') AS distinct_iatas`+groupedScopeCol+` FROM transmissions t LEFT JOIN observations o ON o.id = ( SELECT id FROM observations WHERE transmission_id = t.id @@ -823,7 +825,6 @@ func (db *DB) resolveNodePubkey(nodeIDOrName string) string { return pk } - // GetTransmissionByID fetches from transmissions table with observer data. func (db *DB) GetTransmissionByID(id int) (map[string]interface{}, error) { selectCols, observerJoin := db.transmissionBaseSQL() @@ -870,7 +871,6 @@ func (db *DB) GetObservationsForHash(hash string) []map[string]interface{} { return obsByTx[txID] } - // GetNodes returns filtered, paginated node list. func (db *DB) GetNodes(limit, offset int, role, search, before, lastHeard, sortBy, region string) ([]map[string]interface{}, int, map[string]int, error) { var where []string @@ -1050,7 +1050,6 @@ func (db *DB) GetNodeByPubkey(pubkey string) (map[string]interface{}, error) { return nil, nil } - // GetRecentTransmissionsForNode returns recent transmissions originated by a // node, identified by exact pubkey match on the indexed from_pubkey column // (#1143). The legacy `name` substring fallback was removed: it produced @@ -1423,7 +1422,6 @@ func (db *DB) GetDistinctIATAs() ([]string, error) { return codes, nil } - // GetNetworkStatus returns overall network health status. func (db *DB) GetNetworkStatus(healthThresholds HealthThresholds) (map[string]interface{}, error) { rows, err := db.conn.Query("SELECT public_key, name, role, last_seen FROM nodes") @@ -1895,8 +1893,8 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . defer rows.Close() type msg struct { - Data map[string]interface{} - Repeats int + Data map[string]interface{} + Repeats int LatestEpoch int64 // max observation timestamp (unix seconds) — issue #1366 } msgMap := make(map[int]*msg, len(pageIDs)) @@ -2021,8 +2019,6 @@ func (db *DB) GetChannelMessages(channelHash string, limit, offset int, region . return messages, total, nil } - - // GetNewTransmissionsSince returns new transmissions after a given ID for WebSocket polling. func (db *DB) GetNewTransmissionsSince(lastID int, limit int) ([]map[string]interface{}, error) { if limit <= 0 { @@ -2896,7 +2892,7 @@ func (db *DB) GetScopeStats(window string) (*ScopeStatsResponse, error) { COALESCE(SUM(CASE WHEN scope_name IS NULL THEN 1 ELSE 0 END), 0) AS unscoped, COALESCE(SUM(CASE WHEN scope_name = '' THEN 1 ELSE 0 END), 0) AS unknown_scope FROM transmissions - WHERE ` + routeTypeTransportSQL + ` AND first_seen >= ? + WHERE `+routeTypeTransportSQL+` AND first_seen >= ? `, since) if err := row.Scan( &resp.Summary.TransportTotal, @@ -2924,7 +2920,7 @@ func (db *DB) GetScopeStats(window string) (*ScopeStatsResponse, error) { rows, err := db.conn.Query(` SELECT scope_name, COUNT(*) AS cnt FROM transmissions - WHERE ` + routeTypeTransportSQL + ` AND scope_name IS NOT NULL AND scope_name != '' AND first_seen >= ? + WHERE `+routeTypeTransportSQL+` AND scope_name IS NOT NULL AND scope_name != '' AND first_seen >= ? GROUP BY scope_name ORDER BY cnt DESC `, since) @@ -2951,7 +2947,7 @@ func (db *DB) GetScopeStats(window string) (*ScopeStatsResponse, error) { COUNT(scope_name) AS scoped, SUM(CASE WHEN scope_name IS NULL THEN 1 ELSE 0 END) AS unscoped FROM transmissions - WHERE ` + routeTypeTransportSQL + ` AND first_seen >= ? + WHERE `+routeTypeTransportSQL+` AND first_seen >= ? GROUP BY bucket ORDER BY bucket `, bucketExpr) @@ -2982,7 +2978,7 @@ func (db *DB) GetScopeStats(window string) (*ScopeStatsResponse, error) { hourRows, err := db.conn.Query(` SELECT scope_name, CAST(strftime('%H', first_seen) AS INTEGER) AS hour, COUNT(*) AS cnt FROM transmissions - WHERE ` + routeTypeTransportSQL + ` AND scope_name IS NOT NULL AND scope_name != '' AND first_seen >= ? + WHERE `+routeTypeTransportSQL+` AND scope_name IS NOT NULL AND scope_name != '' AND first_seen >= ? GROUP BY scope_name, hour `, since) if err != nil { @@ -3174,6 +3170,165 @@ func (db *DB) getChannelScopeRegions(since string) (map[string][]string, error) return result, rows.Err() } +// GetWardrivingStats aggregates activity on the given channel (normally +// "#wardriving") over the requested window: message volume over time, who's +// actively sending, which repeater first relayed each message (raw hash +// prefixes — the caller resolves names via /api/resolve-hops), and which +// observer stations actually heard the traffic. See WardrivingObserverCoverage +// doc for why observer coverage — not sender GPS — is the reliable half of +// a "where did this reach" picture: MeshMapper's #wardriving messages carry +// an anonymous per-session token by default, not the sender's live +// coordinates (those go to MeshMapper's own server via a separate API call +// we have no visibility into). +func (db *DB) GetWardrivingStats(window, channel string) (*WardrivingStatsResponse, error) { + var since string + var bucketExpr string + switch window { + case "1h": + since = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + bucketExpr = `strftime('%Y-%m-%dT%H:', first_seen) || printf('%02d', (CAST(strftime('%M', first_seen) AS INTEGER) / 5) * 5) || ':00Z'` + case "7d": + since = time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) + bucketExpr = `strftime('%Y-%m-%dT', first_seen) || printf('%02d', (CAST(strftime('%H', first_seen) AS INTEGER) / 6) * 6) || ':00:00Z'` + default: + window = "24h" + since = time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) + bucketExpr = `strftime('%Y-%m-%dT%H:00:00Z', first_seen)` + } + + resp := &WardrivingStatsResponse{Window: window, Channel: channel} + + if err := db.conn.QueryRow( + `SELECT COUNT(*) FROM transmissions WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ?`, + channel, since, + ).Scan(&resp.TotalMessages); err != nil { + return nil, fmt.Errorf("wardriving total query: %w", err) + } + + tsQuery := fmt.Sprintf(` + SELECT %s AS bucket, COUNT(*) AS cnt + FROM transmissions + WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ? + GROUP BY bucket + ORDER BY bucket + `, bucketExpr) + tsRows, err := db.conn.Query(tsQuery, channel, since) + if err != nil { + return nil, fmt.Errorf("wardriving timeseries query: %w", err) + } + resp.TimeSeries = make([]WardrivingTimePoint, 0) + for tsRows.Next() { + var pt WardrivingTimePoint + if tsRows.Scan(&pt.T, &pt.Count) == nil { + resp.TimeSeries = append(resp.TimeSeries, pt) + } + } + tsRows.Close() + if err := tsRows.Err(); err != nil { + return nil, fmt.Errorf("wardriving timeseries iteration: %w", err) + } + + senderRows, err := db.conn.Query(` + SELECT json_extract(decoded_json, '$.sender') AS sender, COUNT(*) AS cnt + FROM transmissions + WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ? + AND json_extract(decoded_json, '$.sender') IS NOT NULL + AND json_extract(decoded_json, '$.sender') != '' + GROUP BY sender + ORDER BY cnt DESC + `, channel, since) + if err != nil { + return nil, fmt.Errorf("wardriving senders query: %w", err) + } + resp.TopSenders = make([]WardrivingSenderCount, 0) + for senderRows.Next() { + var sc WardrivingSenderCount + if senderRows.Scan(&sc.Sender, &sc.Count) == nil { + resp.TopSenders = append(resp.TopSenders, sc) + } + } + senderRows.Close() + if err := senderRows.Err(); err != nil { + return nil, fmt.Errorf("wardriving senders iteration: %w", err) + } + + entryRows, err := db.conn.Query(` + SELECT json_extract(o.path_json, '$[0]') AS prefix, + COUNT(*) AS observation_count, + COUNT(DISTINCT o.transmission_id) AS message_count + FROM observations o + JOIN transmissions t ON t.id = o.transmission_id + WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ? + AND o.path_json IS NOT NULL AND json_array_length(o.path_json) > 0 + GROUP BY prefix + ORDER BY observation_count DESC + `, channel, since) + if err != nil { + return nil, fmt.Errorf("wardriving entry points query: %w", err) + } + resp.EntryPoints = make([]WardrivingEntryPrefix, 0) + for entryRows.Next() { + var ep WardrivingEntryPrefix + if entryRows.Scan(&ep.Prefix, &ep.ObservationCount, &ep.MessageCount) == nil { + resp.EntryPoints = append(resp.EntryPoints, ep) + } + } + entryRows.Close() + if err := entryRows.Err(); err != nil { + return nil, fmt.Errorf("wardriving entry points iteration: %w", err) + } + + var obsQuery string + if db.isV3 { + obsQuery = ` + SELECT obs.rowid AS observer_id, obs.name, COALESCE(obs.iata, ''), + COUNT(*) AS observation_count, COUNT(DISTINCT o.transmission_id) AS message_count + FROM observations o + JOIN transmissions t ON t.id = o.transmission_id + JOIN observers obs ON obs.rowid = o.observer_idx + WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ? + GROUP BY observer_id + ORDER BY observation_count DESC` + } else { + obsQuery = ` + SELECT obs.id AS observer_id, obs.name, COALESCE(obs.iata, ''), + COUNT(*) AS observation_count, COUNT(DISTINCT o.transmission_id) AS message_count + FROM observations o + JOIN transmissions t ON t.id = o.transmission_id + JOIN observers obs ON obs.id = o.observer_id + WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ? + GROUP BY observer_id + ORDER BY observation_count DESC` + } + obsRows, err := db.conn.Query(obsQuery, channel, since) + if err != nil { + return nil, fmt.Errorf("wardriving observers query: %w", err) + } + resp.Observers = make([]WardrivingObserverCoverage, 0) + for obsRows.Next() { + var oc WardrivingObserverCoverage + var name sql.NullString + if err := obsRows.Scan(&oc.ObserverID, &name, &oc.IATA, &oc.ObservationCount, &oc.MessageCount); err != nil { + continue + } + oc.ObserverName = name.String + if oc.ObserverName == "" { + oc.ObserverName = oc.ObserverID + } + if coord, ok := iataCoords[strings.ToUpper(strings.TrimSpace(oc.IATA))]; ok { + lat, lon := coord.Lat, coord.Lon + oc.Lat, oc.Lon = &lat, &lon + } + resp.Observers = append(resp.Observers, oc) + } + obsRows.Close() + if err := obsRows.Err(); err != nil { + return nil, fmt.Errorf("wardriving observers iteration: %w", err) + } + + return resp, nil +} + // GetMatchedRegionNames returns the set of scope_name values that have ever // matched at least one transmission still in retention (NULL and empty-string // "unknown" rows are excluded). Used to diff against the operator's diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index f5f92b78..c3c98f39 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -102,6 +102,11 @@ func routeDescriptions() map[string]routeMeta { "GET /api/analytics/subpaths-bulk": {Summary: "Bulk subpath analysis", Tag: "analytics"}, "GET /api/analytics/subpath-detail": {Summary: "Subpath detail", Tag: "analytics"}, "GET /api/analytics/neighbor-graph": {Summary: "Neighbor graph", Description: "Full neighbor affinity graph for visualization.", Tag: "analytics"}, + "GET /api/analytics/wardriving": {Summary: "Wardriving channel analytics", Description: "Activity/entry-point/coverage analytics for the #wardriving channel (or another channel via ?channel=): message volume over time, top senders, path[0] entry-point hash-prefix tallies (resolve names via /api/resolve-hops), and per-observer coverage (observer's known IATA-derived coordinates, not the sender's — MeshMapper's wardriving messages carry an anonymous session token by default, not live GPS). Cached 30s per window+channel.", Tag: "analytics", + QueryParams: []paramMeta{ + {Name: "window", Description: "Time window: 1h, 24h (default), or 7d", Type: "string"}, + {Name: "channel", Description: "Channel name to analyze (default #wardriving)", Type: "string"}, + }}, // Channels "GET /api/channels": {Summary: "List channels", Description: "Returns known mesh channels with message counts.", Tag: "channels"}, diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 93fa1127..35d3814d 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -66,6 +66,11 @@ type Server struct { scopeStatsCache map[string]*ScopeStatsResponse scopeStatsCachedAt map[string]time.Time + // Cached /api/analytics/wardriving response — per-window, recomputed at most once every 30s + wardrivingStatsMu sync.Mutex + wardrivingStatsCache map[string]*WardrivingStatsResponse + wardrivingStatsCachedAt map[string]time.Time + // Router reference for OpenAPI spec generation router *mux.Router @@ -231,6 +236,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/health", s.handleHealth).Methods("GET") r.HandleFunc("/api/stats", s.handleStats).Methods("GET") r.HandleFunc("/api/scope-stats", s.handleScopeStats).Methods("GET") + r.HandleFunc("/api/analytics/wardriving", s.handleWardrivingStats).Methods("GET") r.HandleFunc("/api/perf", s.handlePerf).Methods("GET") r.HandleFunc("/api/perf/io", s.handlePerfIO).Methods("GET") r.HandleFunc("/api/perf/sqlite", s.handlePerfSqlite).Methods("GET") @@ -3707,6 +3713,54 @@ func (s *Server) handleScopeStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, resp) } +// handleWardrivingStats serves activity/entry-point/coverage analytics for +// the #wardriving channel (see GetWardrivingStats doc). Same per-window +// 30s-cache shape as handleScopeStats. +func (s *Server) handleWardrivingStats(w http.ResponseWriter, r *http.Request) { + const wardrivingStatsTTL = 30 * time.Second + + window := r.URL.Query().Get("window") + if window == "" { + window = "24h" + } + if window != "1h" && window != "24h" && window != "7d" { + writeError(w, 400, "window must be 1h, 24h, or 7d") + return + } + channel := r.URL.Query().Get("channel") + if channel == "" { + channel = "#wardriving" + } + cacheKey := window + "|" + channel + + s.wardrivingStatsMu.Lock() + if s.wardrivingStatsCache != nil { + if cached, ok := s.wardrivingStatsCache[cacheKey]; ok && time.Since(s.wardrivingStatsCachedAt[cacheKey]) < wardrivingStatsTTL { + s.wardrivingStatsMu.Unlock() + writeJSON(w, cached) + return + } + } + s.wardrivingStatsMu.Unlock() + + resp, err := s.db.GetWardrivingStats(window, channel) + if err != nil { + writeError(w, 500, err.Error()) + return + } + + s.wardrivingStatsMu.Lock() + if s.wardrivingStatsCache == nil { + s.wardrivingStatsCache = make(map[string]*WardrivingStatsResponse) + s.wardrivingStatsCachedAt = make(map[string]time.Time) + } + s.wardrivingStatsCache[cacheKey] = resp + s.wardrivingStatsCachedAt[cacheKey] = time.Now() + s.wardrivingStatsMu.Unlock() + + writeJSON(w, resp) +} + // handlePruneGeoFilter identifies (dry_run=true, default) or enqueues (confirm=true) // deletion of nodes whose GPS coordinates fall outside the currently configured // geo_filter. Nodes with no GPS fix are always kept. Requires geo_filter to be diff --git a/cmd/server/types.go b/cmd/server/types.go index f04e9a49..55d50032 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -211,6 +211,56 @@ type BridgeRepeater struct { Count int `json:"count"` } +// ─── Wardriving ──────────────────────────────────────────────────────────────── + +type WardrivingTimePoint struct { + T string `json:"t"` + Count int `json:"count"` +} + +type WardrivingSenderCount struct { + Sender string `json:"sender"` + Count int `json:"count"` +} + +// WardrivingEntryPrefix is a raw path[0] hash-prefix tally — path[0] is the +// hop closest to the originator (see neighbor_graph.go's "Edge 1: originator +// ↔ path[0]" convention), i.e. which local repeater first relayed this +// wardriving message. The frontend resolves prefixes to repeater names via +// /api/resolve-hops, keeping only unique_prefix-confidence matches — same +// discipline as the Foreign Traffic tab's Entry Points section. +type WardrivingEntryPrefix struct { + Prefix string `json:"prefix"` + ObservationCount int `json:"observationCount"` + MessageCount int `json:"messageCount"` // distinct transmissions this prefix appeared as path[0] for +} + +// WardrivingObserverCoverage is how much wardriving traffic a given observer +// station actually heard — observers sit at fixed, known locations (unlike +// the wardriving sender, whose live GPS is deliberately not carried on-air +// by MeshMapper's default privacy-preserving anonymous-token mode), so this +// is the reliable half of a coverage picture: "where do we know wardriving +// signal actually reached." +type WardrivingObserverCoverage struct { + ObserverID string `json:"observerId"` + ObserverName string `json:"observerName"` + IATA string `json:"iata,omitempty"` + Lat *float64 `json:"lat,omitempty"` + Lon *float64 `json:"lon,omitempty"` + ObservationCount int `json:"observationCount"` + MessageCount int `json:"messageCount"` // distinct transmissions this observer heard +} + +type WardrivingStatsResponse struct { + Window string `json:"window"` + Channel string `json:"channel"` + TotalMessages int `json:"totalMessages"` + TimeSeries []WardrivingTimePoint `json:"timeSeries"` + TopSenders []WardrivingSenderCount `json:"topSenders"` + EntryPoints []WardrivingEntryPrefix `json:"entryPoints"` + Observers []WardrivingObserverCoverage `json:"observers"` +} + // ─── Health ──────────────────────────────────────────────────────────────────── type MemoryStats struct { diff --git a/cmd/server/wardriving_stats_test.go b/cmd/server/wardriving_stats_test.go new file mode 100644 index 00000000..a3a86b5a --- /dev/null +++ b/cmd/server/wardriving_stats_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" +) + +// TestHandleWardrivingStats covers the three sections item-by-item: +// activity (total + top senders), entry points (path[0] tally), and +// observer coverage (joined against the static iataCoords table). +func TestHandleWardrivingStats(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + + // Two senders, three messages: Alice sends 2, Bob sends 1. + insertTx := func(hash, decodedJSON string) int64 { + res, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`, + "aa", hash, now, decodedJSON, + ) + if err != nil { + t.Fatalf("insert tx %s: %v", hash, err) + } + id, _ := res.LastInsertId() + return id + } + tx1 := insertTx("wd1", `{"sender":"Alice","text":"Alice: MM:abc123"}`) + tx2 := insertTx("wd2", `{"sender":"Alice","text":"Alice: MM:def456"}`) + tx3 := insertTx("wd3", `{"sender":"Bob","text":"Bob: MM:ghi789"}`) + + // A non-wardriving channel message must never leak into the results. + insertTx2 := func(hash, channel string) { + if _, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,?,?)`, + "aa", hash, now, channel, `{"sender":"Eve","text":"Eve: hi"}`, + ); err != nil { + t.Fatalf("insert other-channel tx: %v", err) + } + } + insertTx2("other1", "#test") + + // Seed observers: one with a known IATA (coordinates resolvable), one without. + // Schema is v3 (observations.observer_idx references observers.rowid, NOT + // the TEXT id column) — capture each insert's rowid via LastInsertId. + insertObserver := func(id, name, iata string) int64 { + res, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, id, name, iata) + if err != nil { + t.Fatalf("insert observer %s: %v", id, err) + } + rowid, _ := res.LastInsertId() + return rowid + } + seaIdx := insertObserver("obsSEA", "SeattleObs", "SEA") + zzzIdx := insertObserver("obsXXX", "UnknownObs", "ZZZ") + + insertObs := func(txID int64, observerIdx int64, pathJSON string) { + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,1.0,-90,?,?)`, + txID, observerIdx, pathJSON, time.Now().Unix(), + ); err != nil { + t.Fatalf("insert observation: %v", err) + } + } + // tx1: two observations, both via entry prefix "AAAA", one from each observer. + insertObs(tx1, seaIdx, `["AAAA","1111"]`) + insertObs(tx1, zzzIdx, `["AAAA","2222"]`) + // tx2: entry prefix "BBBB", heard only by SEA. + insertObs(tx2, seaIdx, `["BBBB"]`) + // tx3: entry prefix "AAAA" again (same prefix as tx1 — tallies together), heard by SEA. + insertObs(tx3, seaIdx, `["AAAA","3333"]`) + + req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var resp WardrivingStatsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + + if resp.Channel != "#wardriving" { + t.Errorf("Channel = %q, want #wardriving", resp.Channel) + } + if resp.TotalMessages != 3 { + t.Errorf("TotalMessages = %d, want 3 (the #test message must not count)", resp.TotalMessages) + } + + // Top senders: Alice (2) before Bob (1). + if len(resp.TopSenders) != 2 { + t.Fatalf("TopSenders = %+v, want 2 entries", resp.TopSenders) + } + if resp.TopSenders[0].Sender != "Alice" || resp.TopSenders[0].Count != 2 { + t.Errorf("TopSenders[0] = %+v, want {Alice 2}", resp.TopSenders[0]) + } + if resp.TopSenders[1].Sender != "Bob" || resp.TopSenders[1].Count != 1 { + t.Errorf("TopSenders[1] = %+v, want {Bob 1}", resp.TopSenders[1]) + } + + // Entry points: "AAAA" appears in 3 observations (tx1 x2 + tx3 x1) across + // 2 distinct messages (tx1, tx3); "BBBB" appears in 1 observation, 1 message. + if len(resp.EntryPoints) != 2 { + t.Fatalf("EntryPoints = %+v, want 2 prefixes", resp.EntryPoints) + } + if resp.EntryPoints[0].Prefix != "AAAA" || resp.EntryPoints[0].ObservationCount != 3 || resp.EntryPoints[0].MessageCount != 2 { + t.Errorf("EntryPoints[0] = %+v, want {AAAA obs=3 msgs=2}", resp.EntryPoints[0]) + } + if resp.EntryPoints[1].Prefix != "BBBB" || resp.EntryPoints[1].ObservationCount != 1 || resp.EntryPoints[1].MessageCount != 1 { + t.Errorf("EntryPoints[1] = %+v, want {BBBB obs=1 msgs=1}", resp.EntryPoints[1]) + } + + // Observer coverage: SEA heard 3 observations across all 3 messages; + // ZZZ heard 1 observation from 1 message. SEA's IATA resolves to real + // coordinates; ZZZ's unknown IATA leaves Lat/Lon nil. + if len(resp.Observers) != 2 { + t.Fatalf("Observers = %+v, want 2 entries", resp.Observers) + } + sea := resp.Observers[0] + if sea.ObserverName != "SeattleObs" || sea.ObservationCount != 3 || sea.MessageCount != 3 { + t.Errorf("Observers[0] = %+v, want {SeattleObs obs=3 msgs=3}", sea) + } + if sea.Lat == nil || sea.Lon == nil { + t.Error("SEA observer should resolve to known coordinates via iataCoords") + } else if *sea.Lat < 47 || *sea.Lat > 48 { + t.Errorf("SEA lat = %v, want ~47.45 (Seattle)", *sea.Lat) + } + zzz := resp.Observers[1] + if zzz.ObserverName != "UnknownObs" || zzz.ObservationCount != 1 { + t.Errorf("Observers[1] = %+v, want {UnknownObs obs=1}", zzz) + } + if zzz.Lat != nil || zzz.Lon != nil { + t.Errorf("ZZZ has an unrecognized IATA — Lat/Lon should stay nil, got lat=%v lon=%v", zzz.Lat, zzz.Lon) + } + + // Time series should sum back to TotalMessages. + sum := 0 + for _, pt := range resp.TimeSeries { + sum += pt.Count + } + if sum != 3 { + t.Errorf("TimeSeries sums to %d, want 3", sum) + } +} + +// TestHandleWardrivingStats_InvalidWindow mirrors the existing scope-stats +// window validation. +func TestHandleWardrivingStats_InvalidWindow(t *testing.T) { + _, router := setupTestServer(t) + req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=bogus", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 400 { + t.Fatalf("status=%d, want 400 for an invalid window", w.Code) + } +} + +// TestHandleWardrivingStats_EmptyChannel confirms an empty/quiet +// #wardriving channel returns well-formed empty slices, not nulls or an +// error — the frontend always expects arrays it can iterate. +func TestHandleWardrivingStats_EmptyChannel(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + + req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var resp WardrivingStatsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.TotalMessages != 0 { + t.Errorf("TotalMessages = %d, want 0", resp.TotalMessages) + } + if resp.TopSenders == nil || resp.EntryPoints == nil || resp.Observers == nil || resp.TimeSeries == nil { + t.Errorf("expected empty (non-nil) slices, got TopSenders=%v EntryPoints=%v Observers=%v TimeSeries=%v", + resp.TopSenders, resp.EntryPoints, resp.Observers, resp.TimeSeries) + } +} diff --git a/public/analytics.js b/public/analytics.js index 8bce779f..00691aa6 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -35,6 +35,10 @@ function _stopForeignTrafficRefresh() { if (_foreignTrafficRefreshTimer) { clearInterval(_foreignTrafficRefreshTimer); _foreignTrafficRefreshTimer = null; } } + var _wardrivingRefreshTimer = null; + function _stopWardrivingRefresh() { + if (_wardrivingRefreshTimer) { clearInterval(_wardrivingRefreshTimer); _wardrivingRefreshTimer = null; } + } // --- Status color helpers (read from CSS variables for theme support) --- function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } @@ -135,6 +139,7 @@ + @@ -183,6 +188,7 @@ if (_currentTab !== 'roles') _stopRolesRefresh(); if (_currentTab !== 'scopes') _stopScopesRefresh(); if (_currentTab !== 'foreign-traffic') _stopForeignTrafficRefresh(); + if (_currentTab !== 'wardriving') _stopWardrivingRefresh(); _updateAnalyticsUrl(); renderTab(_currentTab); }); @@ -300,6 +306,7 @@ case 'prefix-tool': await renderPrefixTool(el); break; case 'scopes': await renderScopesTab(el); break; case 'foreign-traffic': await renderForeignTrafficTab(el); break; + case 'wardriving': await renderWardrivingTab(el); break; } // Auto-apply column resizing to all analytics tables requestAnimationFrame(() => { @@ -2691,7 +2698,7 @@ } } -function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTrafficRefresh(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } +function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTrafficRefresh(); _stopWardrivingRefresh(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } // Expose for testing if (typeof window !== 'undefined') { @@ -2708,6 +2715,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf window._analyticsRenderCollisionsFromServer = renderCollisionsFromServer; window._analyticsRenderForeignTrafficTab = renderForeignTrafficTab; window._analyticsStopForeignTrafficRefresh = _stopForeignTrafficRefresh; + window._analyticsRenderWardrivingTab = renderWardrivingTab; + window._analyticsStopWardrivingRefresh = _stopWardrivingRefresh; window._analyticsComputeNodesWithoutScope = computeNodesWithoutScope; window._analyticsComputeRepeatersNeverRelayingScope = computeRepeatersNeverRelayingScope; } @@ -5390,6 +5399,202 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf }, 60000); } + // Wardriving analytics: activity/entry-point/coverage for the + // #wardriving channel (MeshMapper's community wardriving convention — + // see /api/analytics/wardriving doc). MeshMapper's on-air message is an + // anonymous per-session token by default, not the sender's live GPS + // (that goes to MeshMapper's own server via a separate API call we + // never see) — so sender location can't be plotted here. What IS + // reliable: which repeater first relayed each message (Entry Points, + // same path[0]/unique_prefix discipline as the Foreign Traffic tab), + // and which observer stations — fixed, known locations — actually + // heard the traffic (Coverage). + async function renderWardrivingTab(el) { + var winKey = 'wardriving_window'; + var selectedWindow = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(winKey)) || '24h'; + + function pct(n, total) { + if (!total) return '—'; + return (n / total * 100).toFixed(1) + '%'; + } + + function cardsHtml(d) { + return [ + { label: 'Messages', value: d.totalMessages.toLocaleString(), note: 'window: ' + d.window }, + { label: 'Active Senders', value: (d.topSenders || []).length.toLocaleString(), note: null }, + { label: 'Entry-Point Repeaters', value: (d.entryPoints || []).length.toLocaleString(), note: 'distinct path[0] prefixes' }, + { label: 'Observers Reached', value: (d.observers || []).length.toLocaleString(), note: null }, + ].map(function(c) { + return '
' + c.value + '
' + + '
' + c.label + '
' + + (c.note ? '
' + c.note + '
' : '') + + '
'; + }).join(''); + } + + // Single-line SVG time series — matches the Scopes tab's two-line chart style. + function chartHtml(ts) { + if (!ts || ts.length <= 1) { + return '

Insufficient data points to chart — wait for more wardriving activity in this window.

'; + } + var vals = ts.map(function(p) { return p.count; }); + var maxVal = Math.max(1, Math.max.apply(null, vals)); + var W = 800, H = 160, padL = 44, padT = 10, padR = 10; + var plotW = W - padL - padR, plotH = H - 24 - padT; + var n = ts.length; + var pts = vals.map(function(v, i) { + var x = padL + i * plotW / Math.max(n - 1, 1); + var y = padT + plotH - (v / maxVal) * plotH; + return x.toFixed(1) + ',' + y.toFixed(1); + }).join(' '); + var grid = ''; + for (var gi = 0; gi <= 4; gi++) { + var gy = padT + plotH * gi / 4; + var gv = Math.round(maxVal * (4 - gi) / 4); + grid += ''; + grid += '' + gv + ''; + } + return '' + + grid + + '' + + ''; + } + + function sendersHtml(senders, totalMessages) { + if (!senders || senders.length === 0) { + return '

No wardriving messages in this window.

'; + } + var rows = senders.map(function(s) { + return '' + esc(s.sender) + '' + s.count.toLocaleString() + '' + pct(s.count, totalMessages) + ''; + }).join(''); + return '' + + '' + + '' + rows + '' + + '
SenderMessages% of Total
'; + } + + // Entry Points — resolve raw path[0] hash prefixes to repeater names, + // same unique_prefix-only discipline as the Foreign Traffic tab (a + // non-unique_prefix resolution is a genuine hash collision across + // multiple candidate repeaters — folded into "Ambiguous" rather than + // guessing). + async function entryPointsHtml(prefixes) { + if (!prefixes || prefixes.length === 0) { + return '

No wardriving messages with a relay path in this window.

'; + } + try { + var resp = await api('/resolve-hops?hops=' + prefixes.map(function(p) { return p.prefix; }).join(','), { ttl: CLIENT_TTL.nodeDetail }).catch(function() { return null; }); + var resolved = (resp && resp.resolved) || {}; + var totalObs = prefixes.reduce(function(sum, p) { return sum + p.observationCount; }, 0); + var named = []; + var ambiguousObs = 0, ambiguousMsgs = 0; + prefixes.forEach(function(p) { + var r = resolved[p.prefix]; + if (r && r.confidence === 'unique_prefix') { + named.push({ name: r.name, pubkey: r.pubkey, obs: p.observationCount, msgs: p.messageCount }); + } else { + ambiguousObs += p.observationCount; + ambiguousMsgs += p.messageCount; + } + }); + named.sort(function(a, b) { return b.obs - a.obs; }); + var rows = named.map(function(e) { + return '' + esc(e.name) + '' + + '' + e.obs.toLocaleString() + '' + + '' + pct(e.obs, totalObs) + '' + + '' + e.msgs.toLocaleString() + ''; + }).join('') + (ambiguousObs > 0 + ? 'Ambiguous (hash prefix collides across multiple candidate repeaters)' + + '' + ambiguousObs.toLocaleString() + '' + pct(ambiguousObs, totalObs) + '' + ambiguousMsgs.toLocaleString() + '' + : ''); + return (named.length > 0 || ambiguousObs > 0) + ? '' + + '' + + '' + rows + '' + + '
Entry-Point RepeaterObservations% of ObservationsDistinct Messages
' + : '

No traceable relay path yet for any wardriving message.

'; + } catch (e) { + return '

Failed to resolve entry points.

'; + } + } + + function observersHtml(observers) { + if (!observers || observers.length === 0) { + return '

No observer has heard wardriving traffic in this window.

'; + } + var totalObsCount = observers.reduce(function(sum, o) { return sum + o.observationCount; }, 0); + var rows = observers.map(function(o) { + var loc = (o.lat != null && o.lon != null) ? (o.lat.toFixed(2) + ', ' + o.lon.toFixed(2)) : '—'; + return '' + esc(o.observerName) + '' + + '' + esc(o.iata || '—') + '' + + '' + loc + '' + + '' + o.observationCount.toLocaleString() + '' + + '' + pct(o.observationCount, totalObsCount) + '' + + '' + o.messageCount.toLocaleString() + ''; + }).join(''); + return '' + + '' + + '' + rows + '' + + '
ObserverRegionLat, LonObservations% of ObservationsDistinct Messages
'; + } + + function attachWindowButtons() { + el.querySelectorAll('[data-wdwin]').forEach(function(btn) { + btn.addEventListener('click', function() { + selectedWindow = btn.dataset.wdwin; + if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(winKey, selectedWindow); + load(selectedWindow); + }); + }); + } + + async function load(w) { + var body; + try { + var d = await api('/analytics/wardriving?window=' + encodeURIComponent(w), { ttl: 30000 }); + var entryHtml = await entryPointsHtml(d.entryPoints || []); + body = + '
' + cardsHtml(d) + '
' + + '
' + chartHtml(d.timeSeries) + '
' + + '

Top Senders

' + + '

Who\'s actively wardriving in this window, by message count.

' + + '
' + sendersHtml(d.topSenders, d.totalMessages) + '
' + + '

Entry Points

' + + '

Which local repeater first relayed each wardriving message — the hop closest to the origin (path[0]) across every observed copy.

' + + '
' + entryHtml + '
' + + '

Coverage by Observer

' + + '

Which observer stations actually heard wardriving traffic — observers sit at fixed, known locations, so this is the reliable half of "how far did it reach."

' + + '
' + observersHtml(d.observers) + '
'; + } catch (err) { + body = '
Failed to load wardriving stats: ' + esc(String(err)) + '
'; + } + + el.innerHTML = + '

Wardriving (#wardriving)

' + + '

' + + 'Community coverage-mapping traffic on the #wardriving channel. MeshMapper\'s on-air ping carries an anonymous session token by default, not the sender\'s live GPS — coordinates go to MeshMapper\'s own server separately. What we can see: who\'s active, which repeater first relayed their signal, and which observer stations (fixed, known locations) actually heard it.' + + '

' + + '
' + + ['1h', '24h', '7d'].map(function(v) { + return ''; + }).join('') + + '
' + + body; + attachWindowButtons(); + } + + await load(selectedWindow); + + // Auto-refresh every 60s while this tab is active (matches Roles/Scopes/Foreign Traffic). + _stopWardrivingRefresh(); + _wardrivingRefreshTimer = setInterval(function() { + if (_currentTab !== 'wardriving') { _stopWardrivingRefresh(); return; } + var cur = document.getElementById('analyticsContent'); + if (!cur) { _stopWardrivingRefresh(); return; } + load(selectedWindow); + }, 60000); + } + // #1085 — Roles tab (folded in from former /#/roles page). // Renders distribution of node roles + per-role clock-skew posture. // Auto-refreshes every 60s while the Roles tab is active (matches the diff --git a/test-analytics-wardriving-tab.js b/test-analytics-wardriving-tab.js new file mode 100644 index 00000000..b04ecc15 --- /dev/null +++ b/test-analytics-wardriving-tab.js @@ -0,0 +1,236 @@ +/** + * DOM-rendering tests for the "Wardriving" Analytics tab + * (renderWardrivingTab, public/analytics.js). + * + * Drives the real render function against a stubbed api() that returns a + * fixed /api/analytics/wardriving response (and /api/resolve-hops for the + * Entry Points section), asserting rendered stat cards, table rows, sort + * order, empty states, and the 60s auto-refresh timer lifecycle — same + * harness style as test-analytics-foreign-traffic-tab.js. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +async function testAsync(name, fn) { + try { + await fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} + +function makeSandbox() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '' }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => {}, clearTimeout: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '' }, + getHashParams: function() { return new URLSearchParams((ctx.location.hash.split('?')[1] || '')); }, + CustomEvent: class CustomEvent {}, + Map, Promise, URLSearchParams, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: (cb) => setTimeout(cb, 0), + }; + // Spies (not just no-ops) so the timer-lifecycle test can verify a + // real interval got registered AND really cleared. + let nextIntervalId = 1; + const liveIntervalIds = new Set(); + const clearedIntervalIds = []; + ctx.__liveIntervalIds = liveIntervalIds; + ctx.__clearedIntervalIds = clearedIntervalIds; + ctx.setInterval = function () { + const id = nextIntervalId++; + liveIntervalIds.add(id); + return id; + }; + ctx.clearInterval = function (id) { + liveIntervalIds.delete(id); + clearedIntervalIds.push(id); + }; + vm.createContext(ctx); + return ctx; +} + +function loadInCtx(ctx, file) { + if (!ctx.__payloadLabelsLoaded && file !== 'public/payload-labels.js') { + ctx.__payloadLabelsLoaded = true; + vm.runInContext(fs.readFileSync('public/payload-labels.js', 'utf8'), ctx); + } + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; +} + +function makeAnalyticsSandbox(apiStub) { + const ctx = makeSandbox(); + ctx.getComputedStyle = () => ({ getPropertyValue: () => '' }); + ctx.registerPage = () => {}; + ctx.timeAgo = (iso) => iso ? 'x ago' : '—'; + ctx.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' }; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.connectWS = () => {}; + ctx.invalidateApiCache = () => {}; + ctx.makeColumnsResizable = () => {}; + ctx.initTabBar = () => {}; + ctx.IATA_COORDS_GEO = {}; + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + ctx.fetchAllNodes = async () => ({ nodes: [] }); + ctx.api = apiStub || (() => Promise.resolve({})); + try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) { + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + return ctx; +} + +function fakeEl() { + return { innerHTML: '', querySelector: () => null, querySelectorAll: () => [] }; +} + +// Minimal but representative /api/analytics/wardriving fixture: 2 senders, +// 2 entry-point prefixes (one unique_prefix-resolvable, one ambiguous), +// 2 observers (one with known coordinates, one without). +function makeWardrivingResponse(overrides) { + return Object.assign({ + window: '24h', + channel: '#wardriving', + totalMessages: 3, + timeSeries: [{ t: '2026-07-20T08:00:00Z', count: 1 }, { t: '2026-07-20T09:00:00Z', count: 2 }], + topSenders: [ + { sender: 'Alice', count: 2 }, + { sender: 'Bob', count: 1 }, + ], + entryPoints: [ + { prefix: 'AAAA', observationCount: 3, messageCount: 2 }, + { prefix: 'CCCC', observationCount: 1, messageCount: 1 }, + ], + observers: [ + { observerId: '1', observerName: 'SeattleObs', iata: 'SEA', lat: 47.4502, lon: -122.3088, observationCount: 3, messageCount: 3 }, + { observerId: '2', observerName: 'UnknownObs', iata: 'ZZZ', observationCount: 1, messageCount: 1 }, + ], + }, overrides); +} + +function makeApiStub(wardrivingResp, resolveHopsResp) { + return function (path) { + if (path.indexOf('/analytics/wardriving') === 0) return Promise.resolve(wardrivingResp); + if (path.indexOf('/resolve-hops') === 0) return Promise.resolve(resolveHopsResp || { resolved: {} }); + return Promise.resolve({}); + }; +} + +(async () => { + console.log('\n=== analytics.js: renderWardrivingTab ==='); + + await testAsync('renders stat cards from the API response', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse(), { + resolved: { + AAAA: { name: 'GatewayRepeater', pubkey: 'pkGateway', confidence: 'unique_prefix' }, + }, + })); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + assert.ok(el.innerHTML.includes('>3<'), 'total messages (3) should appear in a stat card'); + assert.ok(el.innerHTML.includes('Active Senders'), 'Active Senders card label should render'); + assert.ok(el.innerHTML.includes('Entry-Point Repeaters'), 'Entry-Point Repeaters card label should render'); + assert.ok(el.innerHTML.includes('Observers Reached'), 'Observers Reached card label should render'); + }); + + await testAsync('Top Senders table is sorted by count and shows % of total', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse())); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const idxAlice = el.innerHTML.indexOf('Alice'); + const idxBob = el.innerHTML.indexOf('Bob'); + assert.ok(idxAlice > -1 && idxBob > -1, 'both senders should be listed'); + assert.ok(idxAlice < idxBob, 'Alice (2 messages) should be listed before Bob (1 message)'); + // Alice: 2 of 3 total = 66.7% + assert.ok(el.innerHTML.includes('66.7%'), 'Alice row should show 66.7% of total messages'); + }); + + await testAsync('Entry Points resolves unique_prefix repeaters and folds ambiguous into one bucket', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse(), { + resolved: { + AAAA: { name: 'GatewayRepeater', pubkey: 'pkGateway', confidence: 'unique_prefix' }, + CCCC: { name: 'BestGuessRepeater', pubkey: 'pkGuess', confidence: 'gps_preference' }, + }, + })); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const startIdx = el.innerHTML.indexOf('Entry Points'); + const endIdx = el.innerHTML.indexOf('Coverage by Observer'); + const section = el.innerHTML.slice(startIdx, endIdx); + assert.ok(section.includes('GatewayRepeater'), 'unique_prefix resolution should show the real repeater name'); + assert.ok(!section.includes('BestGuessRepeater'), 'a non-unique_prefix resolution must not be shown as a specific named repeater'); + assert.ok(section.includes('Ambiguous'), 'the ambiguous prefix should be folded into an explicit Ambiguous bucket'); + // AAAA: 3 of 4 total observations = 75.0% + assert.ok(section.includes('75.0%'), 'GatewayRepeater should show 75.0% of observations (3 of 4)'); + }); + + await testAsync('Coverage by Observer shows resolved coordinates and "—" for an unknown IATA', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse())); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const startIdx = el.innerHTML.indexOf('Coverage by Observer'); + const section = el.innerHTML.slice(startIdx); + assert.ok(section.includes('SeattleObs'), 'observer with known coordinates should be listed'); + assert.ok(section.includes('47.45, -122.31'), 'SeattleObs should show its resolved lat/lon'); + assert.ok(section.includes('UnknownObs'), 'observer without known coordinates should still be listed'); + const idxUnknown = section.indexOf('UnknownObs'); + const unknownRow = section.slice(idxUnknown, section.indexOf('', idxUnknown)); + assert.ok(unknownRow.includes('—'), 'UnknownObs (no resolvable IATA) should show a dash for its location, not blank/null'); + }); + + await testAsync('shows empty-state messages when the window has no wardriving activity', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ + totalMessages: 0, topSenders: [], entryPoints: [], observers: [], timeSeries: [], + }))); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + assert.ok(el.innerHTML.includes('No wardriving messages in this window'), 'senders empty state should show'); + assert.ok(el.innerHTML.includes('No wardriving messages with a relay path'), 'entry points empty state should show'); + assert.ok(el.innerHTML.includes('No observer has heard wardriving traffic'), 'observers empty state should show'); + }); + + await testAsync('rendering registers a real interval, and stop() actually clears it (not a no-op)', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse())); + const stop = ctx.window._analyticsStopWardrivingRefresh; + assert.strictEqual(typeof stop, 'function', '_stopWardrivingRefresh must be exported for testing/cleanup'); + + stop(); // must not throw when no timer is registered yet + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + + assert.strictEqual(ctx.__liveIntervalIds.size, 1, 'rendering should register exactly one live interval'); + stop(); + assert.strictEqual(ctx.__liveIntervalIds.size, 0, 'stop() should clear the registered interval'); + assert.ok(ctx.__clearedIntervalIds.length >= 1, 'clearInterval should have actually been called'); + }); + + console.log('\n════════════════════════════════════════'); + console.log(` Wardriving tab: ${passed} passed, ${failed} failed`); + console.log('════════════════════════════════════════'); + process.exit(failed === 0 ? 0 : 1); +})();