feat: Wardriving channel analytics tab

New GET /api/analytics/wardriving endpoint plus an Analytics tab
covering the three requested angles: activity over time + top
senders, entry-point repeaters (path[0] tally, unique_prefix-only
name resolution), and per-observer coverage using observers' known
IATA coordinates. MeshMapper's on-air ping is an anonymous session
token by default, not the sender's live GPS, so sender position
itself isn't tracked — documented in the tab and OpenAPI description.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-20 13:50:59 +02:00
co-authored by Claude Sonnet 5
parent 524b6326fd
commit ea1d48aca8
7 changed files with 934 additions and 36 deletions
+190 -35
View File
@@ -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_<HEX>" 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_<HEX>" 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
+5
View File
@@ -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"},
+54
View File
@@ -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
+50
View File
@@ -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 {
+193
View File
@@ -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)
}
}
+206 -1
View File
@@ -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 @@
<button class="tab-btn" data-tab="roles">Roles</button>
<button class="tab-btn" data-tab="scopes">Scopes</button>
<button class="tab-btn" data-tab="foreign-traffic">Foreign Traffic</button>
<button class="tab-btn" data-tab="wardriving">Wardriving</button>
<button class="tab-btn" data-tab="prefix-tool">Prefix Tool</button>
</div>
</div>
@@ -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 '<div class="stat-card"><div class="stat-value">' + c.value + '</div>' +
'<div class="stat-label">' + c.label + '</div>' +
(c.note ? '<div class="stat-note text-muted" style="font-size:11px">' + c.note + '</div>' : '') +
'</div>';
}).join('');
}
// Single-line SVG time series — matches the Scopes tab's two-line chart style.
function chartHtml(ts) {
if (!ts || ts.length <= 1) {
return '<p class="text-muted" style="font-size:0.85em">Insufficient data points to chart — wait for more wardriving activity in this window.</p>';
}
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 += '<line x1="' + padL + '" y1="' + gy.toFixed(1) + '" x2="' + (W - padR) + '" y2="' + gy.toFixed(1) + '" stroke="var(--border)" stroke-dasharray="2"/>';
grid += '<text x="' + (padL - 4) + '" y="' + (gy + 4).toFixed(1) + '" text-anchor="end" font-size="9" fill="var(--text-muted)">' + gv + '</text>';
}
return '<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;max-height:' + H + 'px" role="img" aria-label="Wardriving message volume over time">' +
grid +
'<polyline points="' + pts + '" fill="none" stroke="var(--accent)" stroke-width="2"/>' +
'</svg>';
}
function sendersHtml(senders, totalMessages) {
if (!senders || senders.length === 0) {
return '<p class="text-muted" style="font-size:0.85em">No wardriving messages in this window.</p>';
}
var rows = senders.map(function(s) {
return '<tr><td>' + esc(s.sender) + '</td><td>' + s.count.toLocaleString() + '</td><td>' + pct(s.count, totalMessages) + '</td></tr>';
}).join('');
return '<table class="data-table analytics-table">' +
'<thead><tr><th>Sender</th><th>Messages</th><th>% of Total</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
}
// 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 '<p class="text-muted" style="font-size:0.85em">No wardriving messages with a relay path in this window.</p>';
}
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 '<tr><td><a href="#/nodes/' + encodeURIComponent(e.pubkey) + '">' + esc(e.name) + '</a></td>' +
'<td>' + e.obs.toLocaleString() + '</td>' +
'<td>' + pct(e.obs, totalObs) + '</td>' +
'<td>' + e.msgs.toLocaleString() + '</td></tr>';
}).join('') + (ambiguousObs > 0
? '<tr><td class="text-muted">Ambiguous (hash prefix collides across multiple candidate repeaters)</td>' +
'<td>' + ambiguousObs.toLocaleString() + '</td><td>' + pct(ambiguousObs, totalObs) + '</td><td>' + ambiguousMsgs.toLocaleString() + '</td></tr>'
: '');
return (named.length > 0 || ambiguousObs > 0)
? '<table class="data-table analytics-table">' +
'<thead><tr><th>Entry-Point Repeater</th><th>Observations</th><th>% of Observations</th><th>Distinct Messages</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>'
: '<p class="text-muted" style="font-size:0.85em">No traceable relay path yet for any wardriving message.</p>';
} catch (e) {
return '<p class="text-muted">Failed to resolve entry points.</p>';
}
}
function observersHtml(observers) {
if (!observers || observers.length === 0) {
return '<p class="text-muted" style="font-size:0.85em">No observer has heard wardriving traffic in this window.</p>';
}
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 '<tr><td>' + esc(o.observerName) + '</td>' +
'<td>' + esc(o.iata || '—') + '</td>' +
'<td>' + loc + '</td>' +
'<td>' + o.observationCount.toLocaleString() + '</td>' +
'<td>' + pct(o.observationCount, totalObsCount) + '</td>' +
'<td>' + o.messageCount.toLocaleString() + '</td></tr>';
}).join('');
return '<table class="data-table analytics-table">' +
'<thead><tr><th>Observer</th><th>Region</th><th>Lat, Lon</th><th>Observations</th><th>% of Observations</th><th>Distinct Messages</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
}
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 =
'<div id="wardrivingCards" class="stats-grid" style="margin-bottom:16px">' + cardsHtml(d) + '</div>' +
'<div id="wardrivingChart" style="margin-bottom:16px">' + chartHtml(d.timeSeries) + '</div>' +
'<h4 style="margin:16px 0 4px">Top Senders</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">Who\'s actively wardriving in this window, by message count.</p>' +
'<div id="wardrivingSenders">' + sendersHtml(d.topSenders, d.totalMessages) + '</div>' +
'<h4 style="margin:24px 0 4px">Entry Points</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">Which local repeater first relayed each wardriving message — the hop closest to the origin (path[0]) across every observed copy.</p>' +
'<div id="wardrivingEntryPoints">' + entryHtml + '</div>' +
'<h4 style="margin:24px 0 4px">Coverage by Observer</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">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."</p>' +
'<div id="wardrivingObservers">' + observersHtml(d.observers) + '</div>';
} catch (err) {
body = '<div class="text-center" style="color:var(--status-red);padding:20px">Failed to load wardriving stats: ' + esc(String(err)) + '</div>';
}
el.innerHTML =
'<h3 style="margin:0 0 4px">Wardriving (#wardriving)</h3>' +
'<p class="text-muted" style="margin:0 0 16px;font-size:0.85em">' +
'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.' +
'</p>' +
'<div style="margin-bottom:12px">' +
['1h', '24h', '7d'].map(function(v) {
return '<button class="tab-btn' + (selectedWindow === v ? ' active' : '') + '" data-wdwin="' + v + '">' + v + '</button>';
}).join('') +
'</div>' +
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
+236
View File
@@ -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('</tr>', 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);
})();