mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 00:28:00 +00:00
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:
co-authored by
Claude Sonnet 5
parent
524b6326fd
commit
ea1d48aca8
+190
-35
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user