Merge branch 'areas-meshguide-sync'

This commit is contained in:
dborup
2026-07-28 04:59:01 +02:00
8 changed files with 1140 additions and 1 deletions
+218
View File
@@ -0,0 +1,218 @@
package main
import (
"database/sql"
"encoding/json"
"net/http/httptest"
"testing"
"time"
)
// TestComputeAreaDensity covers multi-membership (a node in a narrower
// sub-area also counts toward a broader containing area, same convention
// as computeScopeAdoptionByArea) and the active/degraded/silent breakdown
// using GetNetworkStatus's own thresholds.
func TestComputeAreaDensity(t *testing.T) {
f := func(v float64) *float64 { return &v }
areas := map[string]AreaEntry{
"ODE": {Label: "Odense by", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)},
"DK": {Label: "Danmark (alle)", LatMin: f(54.5), LatMax: f(57.8), LonMin: f(8.0), LonMax: f(15.2)}, // contains ODE
}
thresholds := HealthThresholds{NodeDegradedHours: 1, NodeSilentHours: 24, InfraDegradedHours: 2, InfraSilentHours: 48}
now := time.Now().UTC()
nodes := []areaAnalyticsNode{
{PublicKey: "active1", Role: "repeater", LastSeen: validStr(now.Add(-30 * time.Minute).Format(time.RFC3339)), Lat: 55.40, Lon: 10.40}, // Odense, infra-active
{PublicKey: "degraded1", Role: "client", LastSeen: validStr(now.Add(-3 * time.Hour).Format(time.RFC3339)), Lat: 55.41, Lon: 10.41}, // Odense, node-degraded
{PublicKey: "silent1", Role: "client", LastSeen: validStr(now.Add(-72 * time.Hour).Format(time.RFC3339)), Lat: 55.42, Lon: 10.42}, // Odense, silent
{PublicKey: "outside1", Role: "client", LastSeen: validStr(now.Format(time.RFC3339)), Lat: 51.0, Lon: 4.0}, // outside every area
}
got := computeAreaDensity(nodes, areas, thresholds)
if len(got) != 2 {
t.Fatalf("got %d areas, want 2 (ODE + DK) -- result: %+v", len(got), got)
}
byKey := map[string]AreaDensity{}
for _, d := range got {
byKey[d.AreaKey] = d
}
ode := byKey["ODE"]
if ode.Total != 3 || ode.Active != 1 || ode.Degraded != 1 || ode.Silent != 1 {
t.Errorf("ODE = %+v, want Total=3 Active=1 Degraded=1 Silent=1", ode)
}
if ode.RoleCounts["repeater"] != 1 || ode.RoleCounts["client"] != 2 {
t.Errorf("ODE.RoleCounts = %v, want repeater=1 client=2", ode.RoleCounts)
}
dk := byKey["DK"]
if dk.Total != 3 {
t.Errorf("DK.Total = %d, want 3 (multi-membership: every Odense node also counts toward DK)", dk.Total)
}
}
// TestComputeAreaBridgeNodes covers the core cross-area signal: an edge
// between two nodes in DIFFERENT areas credits both endpoints, an edge
// within the SAME area is ignored, and an edge with an unresolved
// endpoint (NodeB == "") is skipped per the bridge_recomputer.go
// convention.
func TestComputeAreaBridgeNodes(t *testing.T) {
f := func(v float64) *float64 { return &v }
areas := map[string]AreaEntry{
"A": {Label: "Area A", LatMin: f(55.0), LatMax: f(55.1), LonMin: f(10.0), LonMax: f(10.1)},
"B": {Label: "Area B", LatMin: f(56.0), LatMax: f(56.1), LonMin: f(11.0), LonMax: f(11.1)},
}
nodes := []areaAnalyticsNode{
{PublicKey: "bridgea", Name: "BridgeA", Lat: 55.05, Lon: 10.05},
{PublicKey: "bridgeb", Name: "BridgeB", Lat: 56.05, Lon: 11.05},
{PublicKey: "sameareaa1", Name: "SameA1", Lat: 55.06, Lon: 10.06},
{PublicKey: "sameareaa2", Name: "SameA2", Lat: 55.07, Lon: 10.07},
}
g := NewNeighborGraph()
now := time.Now()
snr := 5.0
g.upsertEdge("bridgea", "bridgeb", "aa", "obs", &snr, now) // cross-area A<->B
g.upsertEdge("sameareaa1", "sameareaa2", "bb", "obs", &snr, now) // same-area, must be excluded
g.upsertEdge("bridgea", "unknownnode0000000000000000000000000000000000000000000000000000", "cc", "obs", &snr, now)
// An edge with an unresolved (empty) NodeB -- the ambiguous-prefix
// case upsertEdge can't itself produce -- must be skipped, same
// convention as bridgeEdgesFromGraph in bridge_recomputer.go.
g.mu.Lock()
unresolvedKey := makeEdgeKey("bridgea", "")
g.edges[unresolvedKey] = &NeighborEdge{NodeA: "bridgea", NodeB: "", Count: 5}
g.mu.Unlock()
got := computeAreaBridgeNodes(nodes, areas, g)
byKey := map[string]AreaBridgeNode{}
for _, b := range got {
byKey[b.PublicKey] = b
}
a, ok := byKey["bridgea"]
if !ok {
t.Fatalf("bridgea missing from result: %+v", got)
}
if a.OtherAreaCount != 1 || len(a.OtherAreas) != 1 || a.OtherAreas[0] != "Area B" {
t.Errorf("bridgea = %+v, want OtherAreaCount=1 OtherAreas=[Area B]", a)
}
b, ok := byKey["bridgeb"]
if !ok || b.OtherAreaCount != 1 || b.OtherAreas[0] != "Area A" {
t.Errorf("bridgeb = %+v, want OtherAreaCount=1 OtherAreas=[Area A]", b)
}
if _, ok := byKey["sameareaa1"]; ok {
t.Errorf("sameareaa1 should not appear -- its only edge stays within Area A")
}
}
// TestComputeAreaBridgeNodes_NilGraph confirms a nil graph (no neighbor
// data loaded yet) degrades to an empty result rather than panicking.
func TestComputeAreaBridgeNodes_NilGraph(t *testing.T) {
areas := map[string]AreaEntry{"A": {Label: "Area A"}}
got := computeAreaBridgeNodes(nil, areas, nil)
if got != nil {
t.Errorf("got %+v, want nil", got)
}
}
// TestComputeAreaPositionGaps exercises the real DB path: one node with
// an actual GPS fix, one unpositioned node with a neighbor_edges row
// pointing at a positioned neighbor inside an area (so it should be
// "approximated" into that area), and one unpositioned node with no
// neighbor_edges at all (so it must land in unpositionedNoNeighborFix,
// not any area's Approximated count).
func TestComputeAreaPositionGaps(t *testing.T) {
f := func(v float64) *float64 { return &v }
areas := map[string]AreaEntry{
"ODE": {Label: "Odense by", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)},
}
db := setupTestDB(t)
defer db.conn.Close()
if _, err := db.conn.Exec(`INSERT INTO nodes (public_key, name, lat, lon) VALUES ('realfix01', 'RealFix', 55.40, 10.40)`); err != nil {
t.Fatal(err)
}
if _, err := db.conn.Exec(`INSERT INTO neighbor_edges (node_a, node_b, count) VALUES ('estimateme01', 'realfix01', 5)`); err != nil {
t.Fatal(err)
}
positioned := []areaAnalyticsNode{{PublicKey: "realfix01", Lat: 55.40, Lon: 10.40}}
unpositioned := []RepeaterRef{
{PublicKey: "estimateme01", Name: "EstimateMe"},
{PublicKey: "noneighborfix01", Name: "NoNeighborFix"},
}
gaps, noNeighborFix := computeAreaPositionGaps(db, positioned, unpositioned, areas)
if len(gaps) != 1 {
t.Fatalf("got %d area gaps, want 1: %+v", len(gaps), gaps)
}
ode := gaps[0]
if ode.RealFix != 1 {
t.Errorf("ODE.RealFix = %d, want 1", ode.RealFix)
}
if ode.Approximated != 1 {
t.Errorf("ODE.Approximated = %d, want 1 (estimateme01 via its neighbor realfix01)", ode.Approximated)
}
if noNeighborFix != 1 {
t.Errorf("unpositionedNoNeighborFix = %d, want 1 (noneighborfix01 has no neighbor_edges row)", noNeighborFix)
}
}
// TestHandleAreaAnalytics_NoAreasConfigured confirms the endpoint returns
// an empty (not error) response when the server has no Areas configured,
// matching the openapi.go doc's "Returns an empty response if no Areas
// are configured."
func TestHandleAreaAnalytics_NoAreasConfigured(t *testing.T) {
_, router := setupTestServer(t)
req := httptest.NewRequest("GET", "/api/analytics/areas", 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 AreaAnalyticsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Density) != 0 || len(resp.BridgeNodes) != 0 || len(resp.PositionGaps) != 0 {
t.Errorf("resp = %+v, want all-empty with no Areas configured", resp)
}
}
// TestHandleAreaAnalytics_Populated drives the full HTTP path with a
// configured area and a positioned node, confirming the JSON shape
// matches openapi.go and the density section actually reflects the seed
// data (seedTestData's TestRepeater/TestCompanion/TestRoom nodes sit
// around lat 37.4-37.6, lon -121.9..-122.1).
func TestHandleAreaAnalytics_Populated(t *testing.T) {
srv, router := setupTestServer(t)
f := func(v float64) *float64 { return &v }
srv.cfg.Areas = map[string]AreaEntry{
"BAY": {Label: "Bay Area", LatMin: f(37.0), LatMax: f(38.0), LonMin: f(-123.0), LonMax: f(-121.0)},
}
req := httptest.NewRequest("GET", "/api/analytics/areas", 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 AreaAnalyticsResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Density) != 1 || resp.Density[0].AreaKey != "BAY" {
t.Fatalf("resp.Density = %+v, want one BAY entry", resp.Density)
}
if resp.Density[0].Total != 3 {
t.Errorf("BAY.Total = %d, want 3 (seedTestData's three positioned nodes)", resp.Density[0].Total)
}
}
func validStr(s string) sql.NullString {
return sql.NullString{String: s, Valid: true}
}
+245
View File
@@ -3556,6 +3556,251 @@ func computeScopeAdoptionByArea(nodes []nodeAreaScopeInput, areas map[string]Are
return result
}
// areaAnalyticsNode is one node with a real GPS fix plus the role/last_seen
// fields computeAreaDensity needs for its active/degraded/silent breakdown
// (GetNodesForScopeAdoption/nodeAreaScopeInput don't carry these — they
// were built for scope adoption, not health).
type areaAnalyticsNode struct {
PublicKey string
Name string
Role string
LastSeen sql.NullString
Lat, Lon float64
}
// GetNodesForAreaAnalytics returns every node split into those with a real
// GPS fix (positioned, for computeAreaDensity/computeAreaBridgeNodes) and
// those without one (unpositioned, for computeAreaPositionGaps to feed
// through nearestPositionedNeighbor). Same "real fix" convention as
// GetNodesForScopeAdoption: lat/lon both present and non-zero.
func (db *DB) GetNodesForAreaAnalytics() (positioned []areaAnalyticsNode, unpositioned []RepeaterRef, err error) {
rows, err := db.conn.Query("SELECT public_key, name, role, last_seen, lat, lon FROM nodes")
if err != nil {
return nil, nil, fmt.Errorf("nodes for area analytics query: %w", err)
}
defer rows.Close()
for rows.Next() {
var pk string
var name, role, lastSeen sql.NullString
var lat, lon sql.NullFloat64
if rows.Scan(&pk, &name, &role, &lastSeen, &lat, &lon) != nil {
continue
}
displayName := pk
if name.Valid && name.String != "" {
displayName = name.String
}
pkLower := strings.ToLower(pk)
if lat.Valid && lon.Valid && lat.Float64 != 0 && lon.Float64 != 0 {
positioned = append(positioned, areaAnalyticsNode{
PublicKey: pkLower, Name: displayName,
Role: role.String, LastSeen: lastSeen,
Lat: lat.Float64, Lon: lon.Float64,
})
} else {
unpositioned = append(unpositioned, RepeaterRef{Name: displayName, PublicKey: pkLower})
}
}
return positioned, unpositioned, rows.Err()
}
// computeAreaDensity buckets positioned nodes by every containing area
// (AreaKeysForPoint, same multi-membership as computeScopeAdoptionByArea —
// a node in "Aarhus by" also counts toward "Jylland") and tallies role mix
// plus the same active/degraded/silent breakdown GetNetworkStatus uses
// network-wide, but per area.
func computeAreaDensity(nodes []areaAnalyticsNode, areas map[string]AreaEntry, healthThresholds HealthThresholds) []AreaDensity {
now := time.Now().UnixMilli()
counts := make(map[string]*AreaDensity)
for _, n := range nodes {
keys := AreaKeysForPoint(n.Lat, n.Lon, areas)
if len(keys) == 0 {
continue
}
role := n.Role
if role == "" {
role = "unknown"
}
age := int64(math.MaxInt64)
if n.LastSeen.Valid {
if t, err := time.Parse(time.RFC3339, n.LastSeen.String); err == nil {
age = now - t.UnixMilli()
} else if t, err := time.Parse("2006-01-02 15:04:05", n.LastSeen.String); err == nil {
age = now - t.UnixMilli()
}
}
degradedMs, silentMs := healthThresholds.GetHealthMs(role)
status := "silent"
if age < int64(degradedMs) {
status = "active"
} else if age < int64(silentMs) {
status = "degraded"
}
for _, key := range keys {
c, exists := counts[key]
if !exists {
a := areas[key]
c = &AreaDensity{AreaKey: key, Label: a.Label, RoleCounts: map[string]int{}}
counts[key] = c
}
c.Total++
switch status {
case "active":
c.Active++
case "degraded":
c.Degraded++
default:
c.Silent++
}
c.RoleCounts[role]++
}
}
result := make([]AreaDensity, 0, len(counts))
for _, c := range counts {
result = append(result, *c)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Total != result[j].Total {
return result[i].Total > result[j].Total
}
return result[i].Label < result[j].Label
})
return result
}
// computeAreaBridgeNodes ranks positioned nodes by how many OTHER areas
// their packet-derived neighbor_edges reach into — the "who's actually
// load-bearing between areas" list. Unlike computeAreaDensity's
// multi-membership, each node here uses its single most-specific area
// (AreaKeyForPoint) since a bridge node needs one home to measure "other"
// against. Distinct from bridge_score (bridge_recomputer.go): that's
// network-wide betweenness centrality with no area awareness at all.
func computeAreaBridgeNodes(nodes []areaAnalyticsNode, areas map[string]AreaEntry, graph *NeighborGraph) []AreaBridgeNode {
if graph == nil || len(areas) == 0 {
return nil
}
type nodeMeta struct {
name string
areaKey string
label string
}
byPubkey := make(map[string]nodeMeta, len(nodes))
for _, n := range nodes {
key, ok := AreaKeyForPoint(n.Lat, n.Lon, areas)
if !ok {
continue
}
byPubkey[n.PublicKey] = nodeMeta{name: n.Name, areaKey: key, label: areas[key].Label}
}
if len(byPubkey) == 0 {
return nil
}
bridges := make(map[string]*AreaBridgeNode)
otherAreaSets := make(map[string]map[string]bool)
for _, e := range graph.AllEdges() {
if e == nil || e.NodeA == "" || e.NodeB == "" {
continue
}
a, aOK := byPubkey[strings.ToLower(e.NodeA)]
b, bOK := byPubkey[strings.ToLower(e.NodeB)]
if !aOK || !bOK || a.areaKey == b.areaKey {
continue
}
for _, pair := range []struct {
pk string
self nodeMeta
other nodeMeta
}{
{strings.ToLower(e.NodeA), a, b},
{strings.ToLower(e.NodeB), b, a},
} {
bn, exists := bridges[pair.pk]
if !exists {
bn = &AreaBridgeNode{PublicKey: pair.pk, Name: pair.self.name, AreaKey: pair.self.areaKey, Label: pair.self.label}
bridges[pair.pk] = bn
otherAreaSets[pair.pk] = make(map[string]bool)
}
bn.EdgeCount++
if !otherAreaSets[pair.pk][pair.other.label] {
otherAreaSets[pair.pk][pair.other.label] = true
bn.OtherAreas = append(bn.OtherAreas, pair.other.label)
}
}
}
result := make([]AreaBridgeNode, 0, len(bridges))
for _, bn := range bridges {
sort.Strings(bn.OtherAreas)
bn.OtherAreaCount = len(bn.OtherAreas)
result = append(result, *bn)
}
sort.Slice(result, func(i, j int) bool {
if result[i].OtherAreaCount != result[j].OtherAreaCount {
return result[i].OtherAreaCount > result[j].OtherAreaCount
}
if result[i].EdgeCount != result[j].EdgeCount {
return result[i].EdgeCount > result[j].EdgeCount
}
return result[i].Name < result[j].Name
})
if len(result) > 25 {
result = result[:25]
}
return result
}
// computeAreaPositionGaps reports, per area, how many nodes have a real
// GPS fix vs. how many were only reachable via nearestPositionedNeighbor's
// weighted-centroid estimate (the same technique View Path's "approx"
// markers use) — purely as an internal coverage signal here, not exposed
// as a map pin. Each unpositioned node's estimated point lands in exactly
// one most-specific area (AreaKeyForPoint), same reasoning as
// computeAreaBridgeNodes. Nodes with no positioned neighbor to estimate
// from at all (nearestPositionedNeighbor ok=false) can't be placed
// anywhere and are counted only in unpositionedNoNeighborFix, not in any
// area's Approximated total.
func computeAreaPositionGaps(db *DB, positioned []areaAnalyticsNode, unpositioned []RepeaterRef, areas map[string]AreaEntry) (gaps []AreaPositionGap, unpositionedNoNeighborFix int) {
counts := make(map[string]*AreaPositionGap)
get := func(key string) *AreaPositionGap {
g, exists := counts[key]
if !exists {
g = &AreaPositionGap{AreaKey: key, Label: areas[key].Label}
counts[key] = g
}
return g
}
for _, n := range positioned {
key, ok := AreaKeyForPoint(n.Lat, n.Lon, areas)
if !ok {
continue
}
get(key).RealFix++
}
for _, n := range unpositioned {
_, estLat, estLon, _, _, ok := db.nearestPositionedNeighbor(n.PublicKey)
if !ok {
unpositionedNoNeighborFix++
continue
}
key, ok := AreaKeyForPoint(estLat, estLon, areas)
if !ok {
continue
}
get(key).Approximated++
}
result := make([]AreaPositionGap, 0, len(counts))
for _, g := range counts {
result = append(result, *g)
}
sort.Slice(result, func(i, j int) bool {
if result[i].RealFix != result[j].RealFix {
return result[i].RealFix > result[j].RealFix
}
return result[i].Label < result[j].Label
})
return result, unpositionedNoNeighborFix
}
// QueryMultiNodePackets returns transmissions referencing any of the given pubkeys.
func (db *DB) QueryMultiNodePackets(pubkeys []string, limit, offset int, order, since, until string) (*PacketResult, error) {
if len(pubkeys) == 0 {
+49
View File
@@ -149,6 +149,8 @@ func routeDescriptions() map[string]routeMeta {
"GET /api/audio-lab/buckets": {Summary: "Audio lab frequency buckets", Description: "Returns frequency bucket data for audio analysis.", Tag: "analytics"},
"GET /api/ping-scores": {Summary: "Ping-score highscore board", Description: "Global (not scoped by region/area) records and leaderboards derived from every ping-bot-triggering channel message ever seen: farthest reach, most hops, widest simultaneous spread, fastest full spread, and most airtime-efficient ping, plus which relay nodes and which observers appear most often. Computed from the same GetPacketPath + LoRa-airtime-estimate logic behind /api/packets/{hash}/path and refreshed on a background interval, so it may lag the very latest ping by a few minutes. Fields are omitted (not zero) until at least one qualifying ping has been recorded.", Tag: "packets",
Response: schemaRef("PingScoresResponse")},
"GET /api/analytics/areas": {Summary: "Per-configured-Area node density, cross-area bridge nodes, and position-fix coverage", Description: "Three breakdowns over the drawn-polygon Areas configured via the meshguide.dk sync, distinct from hashRegion scope adoption (see /api/analytics/scope-stats): (1) density, node count/active-degraded-silent health/role mix per area (multi-membership via AreaKeysForPoint, so a node in a sub-area also counts toward its parent region), (2) bridgeNodes, nodes whose packet-derived neighbor_edges reach into at least one OTHER area (single most-specific area via AreaKeyForPoint), ranked by how many other areas they reach -- distinct from the network-wide, area-unaware bridge_score betweenness centrality, (3) positionGaps, per area how many nodes have a real GPS fix vs. how many were only placeable via the same neighbor-centroid estimate View Path's approx markers use (nearestPositionedNeighbor), used here purely as an internal coverage signal, not a map pin. Returns an empty response if no Areas are configured. Cached 30s.", Tag: "analytics",
Response: schemaRef("AreaAnalyticsResponse")},
}
}
@@ -445,6 +447,53 @@ func componentSchemas() map[string]interface{} {
"observerLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top observers ranked by number of pings they were the first station to hear."},
},
},
"AreaDensity": map[string]interface{}{
"type": "object",
"description": "One configured area's node count, active/degraded/silent health breakdown, and role mix. Multi-membership: a node in a sub-area also counts toward its parent region.",
"properties": map[string]interface{}{
"areaKey": str("The area's config key."),
"label": str("The area's display label."),
"total": map[string]interface{}{"type": "integer", "description": "Total nodes with a real GPS fix inside this area (or any of its sub-areas)."},
"active": map[string]interface{}{"type": "integer", "description": "Nodes heard within their role's active threshold."},
"degraded": map[string]interface{}{"type": "integer", "description": "Nodes heard within their role's degraded threshold but not active."},
"silent": map[string]interface{}{"type": "integer", "description": "Nodes not heard within either threshold."},
"roleCounts": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "integer"}, "description": "Node count per role string."},
},
},
"AreaBridgeNode": map[string]interface{}{
"type": "object",
"description": "One node whose packet-derived neighbor_edges reach into at least one other configured area than its own -- distinct from the network-wide, area-unaware bridge_score betweenness centrality.",
"properties": map[string]interface{}{
"publicKey": str("The node's pubkey."),
"name": str("Display name, falling back to the raw pubkey when unresolved."),
"areaKey": str("This node's own single most-specific area."),
"label": str("That area's display label."),
"edgeCount": map[string]interface{}{"type": "integer", "description": "Number of neighbor_edges reaching into a different area than this node's own."},
"otherAreaCount": map[string]interface{}{"type": "integer", "description": "Number of distinct other areas reached."},
"otherAreas": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "Display labels of every other area reached."},
},
},
"AreaPositionGap": map[string]interface{}{
"type": "object",
"description": "One configured area's position-fix coverage: nodes with a real GPS fix vs. nodes only placeable via nearestPositionedNeighbor's estimate.",
"properties": map[string]interface{}{
"areaKey": str("The area's config key."),
"label": str("The area's display label."),
"realFix": map[string]interface{}{"type": "integer", "description": "Nodes in this area with an actual reported GPS position."},
"approximated": map[string]interface{}{"type": "integer", "description": "Nodes with no GPS fix whose neighbor-centroid estimate landed in this area."},
},
},
"AreaAnalyticsResponse": map[string]interface{}{
"type": "object",
"description": "Node density/health, cross-area bridge nodes, and position-fix coverage per configured Area (the drawn-polygon regions from the meshguide.dk sync, distinct from hashRegion scope adoption). Empty when no Areas are configured.",
"properties": map[string]interface{}{
"density": map[string]interface{}{"type": "array", "items": schemaRef("AreaDensity")},
"bridgeNodes": map[string]interface{}{"type": "array", "items": schemaRef("AreaBridgeNode"), "description": "Top cross-area bridge nodes, ranked by how many other areas they reach."},
"positionGaps": map[string]interface{}{"type": "array", "items": schemaRef("AreaPositionGap")},
"unpositionedTotal": map[string]interface{}{"type": "integer", "description": "Every node with no real GPS fix, regardless of area."},
"unpositionedNoNeighborFix": map[string]interface{}{"type": "integer", "description": "The subset of unpositionedTotal that also has no positioned neighbor to estimate from -- can't be placed even approximately, so absent from every area's positionGaps.approximated."},
},
},
}
}
+62
View File
@@ -116,6 +116,15 @@ type Server struct {
// Ping-score highscore/leaderboard cache, refreshed by
// StartPingScoresRecomputer (see ping_scores.go).
pingScores pingScoresCache
// Cached /api/analytics/areas response, recomputed at most once every
// 30s. Worth a TTL cache: computeAreaPositionGaps calls
// nearestPositionedNeighbor once per unpositioned node, each a couple
// of small queries -- cheap individually but adds up when many nodes
// lack a real fix (the exact situation this endpoint exists to report on).
areaAnalyticsMu sync.Mutex
areaAnalyticsCache *AreaAnalyticsResponse
areaAnalyticsCachedAt time.Time
}
// PerfStats tracks request performance.
@@ -258,6 +267,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/config/areas", s.handleConfigAreas).Methods("GET")
r.HandleFunc("/api/config/areas/polygons", s.handleConfigAreasPolygons).Methods("GET")
r.HandleFunc("/api/ping-scores", s.handlePingScores).Methods("GET")
r.HandleFunc("/api/analytics/areas", s.handleAreaAnalytics).Methods("GET")
r.Handle("/api/config/geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT")
// Readiness endpoint (gated on background init completion)
@@ -560,6 +570,58 @@ func (s *Server) handlePingScores(w http.ResponseWriter, r *http.Request) {
writeJSON(w, snap)
}
// handleAreaAnalytics serves the node density/health, cross-area bridge
// node, and position-fix coverage gap breakdowns for every configured
// Area (public/analytics.js's "Areas" tab). Cached for 30s: like
// areaAnalyticsCache's field comment explains, computeAreaPositionGaps
// calls nearestPositionedNeighbor once per unpositioned node, which adds
// up on networks with many nodes lacking a real GPS fix.
func (s *Server) handleAreaAnalytics(w http.ResponseWriter, r *http.Request) {
const areaAnalyticsTTL = 30 * time.Second
s.areaAnalyticsMu.Lock()
if s.areaAnalyticsCache != nil && time.Since(s.areaAnalyticsCachedAt) < areaAnalyticsTTL {
cached := s.areaAnalyticsCache
s.areaAnalyticsMu.Unlock()
writeJSON(w, cached)
return
}
s.areaAnalyticsMu.Unlock()
if s.cfg == nil || len(s.cfg.Areas) == 0 {
writeJSON(w, &AreaAnalyticsResponse{})
return
}
positioned, unpositioned, err := s.db.GetNodesForAreaAnalytics()
if err != nil {
writeError(w, 500, err.Error())
return
}
var graph *NeighborGraph
if s.store != nil {
graph = s.store.graph.Load()
}
positionGaps, noNeighborFix := computeAreaPositionGaps(s.db, positioned, unpositioned, s.cfg.Areas)
resp := &AreaAnalyticsResponse{
Density: computeAreaDensity(positioned, s.cfg.Areas, s.cfg.GetHealthThresholds()),
BridgeNodes: computeAreaBridgeNodes(positioned, s.cfg.Areas, graph),
PositionGaps: positionGaps,
UnpositionedTotal: len(unpositioned),
UnpositionedNoNeighborFix: noNeighborFix,
}
s.areaAnalyticsMu.Lock()
s.areaAnalyticsCache = resp
s.areaAnalyticsCachedAt = time.Now()
s.areaAnalyticsMu.Unlock()
writeJSON(w, resp)
}
func (s *Server) handleConfigRegions(w http.ResponseWriter, r *http.Request) {
regions := make(map[string]string)
for k, v := range s.cfg.Regions {
+67
View File
@@ -255,6 +255,73 @@ type AreaScopeMatch struct {
MatchedScopes []string `json:"matchedScopes"`
}
// AreaAnalyticsResponse bundles the Area-based analytics dborup asked for
// after seeing the neighbor-centroid position approximation built for View
// Path: node density/health per area, cross-area bridge nodes (via the
// packet-derived neighbor_edges graph -- distinct from bridge_score's
// network-wide betweenness centrality in bridge_recomputer.go, which has no
// area awareness at all), and position-fix coverage gaps per area (reusing
// nearestPositionedNeighbor, the same estimation View Path's approximate
// markers use, purely as an internal analytics signal here -- not exposed
// as a map pin).
type AreaAnalyticsResponse struct {
Density []AreaDensity `json:"density"`
BridgeNodes []AreaBridgeNode `json:"bridgeNodes"`
PositionGaps []AreaPositionGap `json:"positionGaps"`
// UnpositionedTotal is every node with no real GPS fix, regardless of
// area. UnpositionedNoNeighborFix is the subset that ALSO has no
// positioned neighbor to estimate from (nearestPositionedNeighbor
// returns ok=false) -- these can't be placed anywhere, not even
// approximately, and so don't appear in PositionGaps' Approximated
// counts at all.
UnpositionedTotal int `json:"unpositionedTotal"`
UnpositionedNoNeighborFix int `json:"unpositionedNoNeighborFix"`
}
// AreaDensity is one configured area's node count, health breakdown
// (active/degraded/silent, same classification and thresholds as
// GetNetworkStatus), and role mix. Uses AreaKeysForPoint (multi-membership
// like computeScopeAdoptionByArea) so a node in "Aarhus by" also counts
// toward "Jylland"/"Danmark (alle)".
type AreaDensity struct {
AreaKey string `json:"areaKey"`
Label string `json:"label"`
Total int `json:"total"`
Active int `json:"active"`
Degraded int `json:"degraded"`
Silent int `json:"silent"`
RoleCounts map[string]int `json:"roleCounts"`
}
// AreaBridgeNode is one node whose packet-derived neighbor_edges reach
// into at least one OTHER area than its own -- ranked by OtherAreaCount,
// the "who's actually load-bearing between areas" list. Uses each node's
// single most-specific area (AreaKeyForPoint), unlike AreaDensity's
// multi-membership, since a bridge node needs one home to measure
// "other" against.
type AreaBridgeNode struct {
PublicKey string `json:"publicKey"`
Name string `json:"name"`
AreaKey string `json:"areaKey"`
Label string `json:"label"`
EdgeCount int `json:"edgeCount"`
OtherAreaCount int `json:"otherAreaCount"`
OtherAreas []string `json:"otherAreas"`
}
// AreaPositionGap is one configured area's position-fix coverage: how many
// of its nodes have a real GPS fix vs. how many were only reachable via
// nearestPositionedNeighbor's estimate. Uses each node's single
// most-specific area for the SAME reason AreaBridgeNode does -- an
// estimated position is one point, which lands in exactly one
// most-specific area, not several.
type AreaPositionGap struct {
AreaKey string `json:"areaKey"`
Label string `json:"label"`
RealFix int `json:"realFix"`
Approximated int `json:"approximated"`
}
type ScopeRegionRepeaters struct {
Region string `json:"region"`
Count int `json:"count"`
+192 -1
View File
@@ -39,6 +39,10 @@
function _stopWardrivingRefresh() {
if (_wardrivingRefreshTimer) { clearInterval(_wardrivingRefreshTimer); _wardrivingRefreshTimer = null; }
}
var _areasRefreshTimer = null;
function _stopAreasRefresh() {
if (_areasRefreshTimer) { clearInterval(_areasRefreshTimer); _areasRefreshTimer = null; }
}
// --- Status color helpers (read from CSS variables for theme support) ---
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
@@ -140,6 +144,7 @@
<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="areas">Areas</button>
<button class="tab-btn" data-tab="prefix-tool">Prefix Tool</button>
</div>
</div>
@@ -189,6 +194,7 @@
if (_currentTab !== 'scopes') _stopScopesRefresh();
if (_currentTab !== 'foreign-traffic') _stopForeignTrafficRefresh();
if (_currentTab !== 'wardriving') _stopWardrivingRefresh();
if (_currentTab !== 'areas') _stopAreasRefresh();
_updateAnalyticsUrl();
renderTab(_currentTab);
});
@@ -307,6 +313,7 @@
case 'scopes': await renderScopesTab(el); break;
case 'foreign-traffic': await renderForeignTrafficTab(el); break;
case 'wardriving': await renderWardrivingTab(el); break;
case 'areas': await renderAreasTab(el); break;
}
// Auto-apply column resizing to all analytics tables
requestAnimationFrame(() => {
@@ -2698,7 +2705,7 @@
}
}
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; } }
function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTrafficRefresh(); _stopWardrivingRefresh(); _stopAreasRefresh(); _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') {
@@ -2717,6 +2724,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
window._analyticsStopForeignTrafficRefresh = _stopForeignTrafficRefresh;
window._analyticsRenderWardrivingTab = renderWardrivingTab;
window._analyticsStopWardrivingRefresh = _stopWardrivingRefresh;
window._analyticsRenderAreasTab = renderAreasTab;
window._analyticsStopAreasRefresh = _stopAreasRefresh;
window._analyticsComputeNodesWithoutScope = computeNodesWithoutScope;
window._analyticsComputeRepeatersNeverRelayingScope = computeRepeatersNeverRelayingScope;
window._analyticsHopDepthBucketStats = hopDepthBucketStats;
@@ -6506,6 +6515,188 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
}, 60000);
}
// Areas tab: analytics over the drawn-polygon Areas configured via the
// meshguide.dk sync (distinct from hashRegion scope adoption, covered
// by the Scopes tab) — node density/health per area, which nodes act
// as bridges between areas (packet-derived neighbor graph, not the
// network-wide bridge_score), and how much of each area's node count
// has a real GPS fix vs. only an estimated one (same neighbor-centroid
// technique View Path's "approx" markers use, reused here purely as a
// coverage signal — not a map pin).
async function renderAreasTab(el) {
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Loading area analytics…</div>';
await _renderAreasTabBody(el);
_stopAreasRefresh();
_areasRefreshTimer = setInterval(function () {
if (_currentTab !== 'areas') { _stopAreasRefresh(); return; }
var cur = document.getElementById('analyticsContent');
if (!cur) { _stopAreasRefresh(); return; }
_renderAreasTabBody(cur);
}, 60000);
}
async function _renderAreasTabBody(el) {
try {
var d = await api('/analytics/areas', { ttl: 30000 });
var density = (d && d.density) || [];
var bridgeNodes = (d && d.bridgeNodes) || [];
var positionGaps = (d && d.positionGaps) || [];
if (!density.length && !bridgeNodes.length && !positionGaps.length) {
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">No Areas are configured — this tab needs at least one drawn-polygon Area (meshguide.dk sync) to report on.</div>';
return;
}
function pct(n, total) {
if (!total) return '—';
return (n / total * 100).toFixed(1) + '%';
}
// Every section below collapses to the top 10 with a "Show all N"
// toggle, same pattern as the Wardriving tab's Top Senders/Coverage
// by Observer sections — dborup flagged that a flat 36-row dump
// "fylder hele billedet" (fills the whole picture) and asked for
// this treatment across all three Areas sections, not just
// Position-Fix Coverage Gaps where it started.
var AREAS_TOP_N = 10;
// Generic collapsible-table renderer + click wiring shared by all
// three sections below. `items` is assumed already sorted into the
// order that matters for that section (worst-first for Density and
// Position Gaps, most-cross-area-reach-first for Bridge Nodes,
// both already established before this call).
function collapsibleTableHtml(items, theadHtml, rowFn, toggleAttr, noun, emptyMsg, expanded) {
if (!items.length) return emptyMsg;
var shown = expanded ? items : items.slice(0, AREAS_TOP_N);
var rows = shown.map(rowFn).join('');
var toggle = items.length > AREAS_TOP_N
? '<div style="margin-top:6px;text-align:right"><button type="button" ' + toggleAttr + ' class="btn-link" style="font-size:12px;cursor:pointer;background:none;border:none;color:var(--link-color);padding:0">' +
(expanded ? 'Show fewer' : 'Show all ' + items.length.toLocaleString() + ' ' + noun) + '</button></div>'
: '';
return '<table class="analytics-table"><thead><tr>' + theadHtml + '</tr></thead><tbody>' + rows + '</tbody></table>' + toggle;
}
function wireCollapseToggle(containerId, toggleAttr, renderFn) {
var expanded = false;
function attach() {
var container = document.getElementById(containerId);
if (!container) return;
var btn = container.querySelector('[' + toggleAttr + ']');
if (btn) {
btn.addEventListener('click', function () {
expanded = !expanded;
container.innerHTML = renderFn(expanded);
attach();
});
}
}
attach();
}
// Density: sorted by % unhealthy (degraded+silent) descending, not
// the API's Total-desc order — a handful of big, mostly-healthy
// areas (Europa, Danmark) otherwise buried the small areas actually
// worth a look. Ties (including the common all-active 0% case)
// fall back to Total desc so the biggest areas still anchor each
// health tier.
var sortedDensity = density.slice().sort(function (a, b) {
var pctA = a.total ? (a.degraded + a.silent) / a.total : 0;
var pctB = b.total ? (b.degraded + b.silent) / b.total : 0;
if (pctB !== pctA) return pctB - pctA;
return b.total - a.total;
});
function densityRowHtml(a) {
var roleParts = Object.keys(a.roleCounts || {}).sort().map(function (r) {
return r + ': ' + a.roleCounts[r];
}).join(', ');
return '<tr>' +
'<td>' + esc(a.label) + '</td>' +
'<td style="text-align:right">' + a.total.toLocaleString() + '</td>' +
'<td style="text-align:right;color:var(--status-green)">' + a.active.toLocaleString() + '</td>' +
'<td style="text-align:right;color:var(--status-yellow)">' + a.degraded.toLocaleString() + '</td>' +
'<td style="text-align:right;color:var(--status-red)">' + a.silent.toLocaleString() + '</td>' +
'<td class="text-muted" style="font-size:0.85em">' + esc(roleParts) + '</td>' +
'</tr>';
}
var densityThead = '<th scope="col">Area</th><th scope="col">Total</th><th scope="col">Active</th><th scope="col">Degraded</th><th scope="col">Silent</th><th scope="col">Role Mix</th>';
function densityTableHtml(expanded) {
return collapsibleTableHtml(sortedDensity, densityThead, densityRowHtml, 'data-areas-density-toggle', 'areas',
'<p class="text-muted" style="font-size:0.85em">No positioned nodes fall inside any configured area.</p>', expanded);
}
// Bridge nodes: already ranked most-cross-area-reach-first by the
// API (computeAreaBridgeNodes, and capped at 25) -- no re-sort
// needed, just the same collapse-to-10 treatment.
function bridgeRowHtml(b) {
return '<tr>' +
'<td><a href="#/nodes/' + encodeURIComponent(b.publicKey) + '">' + esc(b.name) + '</a></td>' +
'<td>' + esc(b.label) + '</td>' +
'<td style="text-align:right">' + b.edgeCount.toLocaleString() + '</td>' +
'<td style="text-align:right">' + b.otherAreaCount.toLocaleString() + '</td>' +
'<td class="text-muted" style="font-size:0.85em">' + (b.otherAreas || []).map(esc).join(', ') + '</td>' +
'</tr>';
}
var bridgeThead = '<th scope="col">Node</th><th scope="col">Home Area</th><th scope="col">Cross-Area Edges</th><th scope="col">Other Areas Reached</th><th scope="col">Which Areas</th>';
function bridgeTableHtml(expanded) {
return collapsibleTableHtml(bridgeNodes, bridgeThead, bridgeRowHtml, 'data-areas-bridge-toggle', 'nodes',
'<p class="text-muted" style="font-size:0.85em">No packet-derived neighbor edges cross between two different areas yet.</p>', expanded);
}
// Position gaps: sorted worst-coverage-first (highest % estimated)
// rather than the API's realFix-desc order — most areas have 0%
// estimated (fully GPS-mapped already), so a plain dump buried the
// handful of areas that actually have a gap worth looking at.
var sortedGaps = positionGaps.slice().sort(function (a, b) {
var totalA = a.realFix + a.approximated, totalB = b.realFix + b.approximated;
var pctA = totalA ? a.approximated / totalA : 0;
var pctB = totalB ? b.approximated / totalB : 0;
return pctB - pctA;
});
function gapsRowHtml(g) {
var total = g.realFix + g.approximated;
return '<tr>' +
'<td>' + esc(g.label) + '</td>' +
'<td style="text-align:right">' + g.realFix.toLocaleString() + '</td>' +
'<td style="text-align:right">' + g.approximated.toLocaleString() + '</td>' +
'<td style="text-align:right">' + pct(g.approximated, total) + '</td>' +
'</tr>';
}
var gapsThead = '<th scope="col">Area</th><th scope="col">Real GPS Fix</th><th scope="col">Estimated (via Neighbors)</th><th scope="col">% Estimated</th>';
function gapsTableHtml(expanded) {
return collapsibleTableHtml(sortedGaps, gapsThead, gapsRowHtml, 'data-areas-gaps-toggle', 'areas',
'<p class="text-muted" style="font-size:0.85em">No unpositioned node could be placed in any area, even approximately.</p>', expanded);
}
var unpositionedNote = '<p class="text-muted" style="margin:8px 0 0;font-size:0.85em">' +
(d.unpositionedTotal || 0).toLocaleString() + ' node' + (d.unpositionedTotal === 1 ? '' : 's') + ' network-wide have no real GPS fix' +
(d.unpositionedNoNeighborFix ? ', of which ' + d.unpositionedNoNeighborFix.toLocaleString() + ' also have no positioned neighbor to estimate from — those can\'t be placed anywhere, not even approximately, so they\'re absent from the table above entirely.' : '.') +
'</p>';
el.innerHTML =
'<div class="analytics-card">' +
'<h3>Node Density &amp; Health by Area</h3>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">Nodes with a real GPS fix inside each configured area — a node in a narrower sub-area also counts toward any broader area containing it (e.g. a city area rolls up into its region). Sorted worst-health-first.</p>' +
'<div id="areasDensity">' + densityTableHtml(false) + '</div>' +
'</div>' +
'<div class="analytics-card" style="margin-top:16px">' +
'<h3>Cross-Area Bridge Nodes</h3>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">Nodes whose packet-derived neighbor connections reach into at least one OTHER area than their own, ranked by how many other areas they reach. Distinct from the network-wide Bridge Score elsewhere in this app, which has no concept of areas at all.</p>' +
'<div id="areasBridgeNodes">' + bridgeTableHtml(false) + '</div>' +
'</div>' +
'<div class="analytics-card" style="margin-top:16px">' +
'<h3>Position-Fix Coverage Gaps by Area</h3>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">How many of each area\'s nodes have an actual reported GPS position vs. how many were only placeable via a neighbor-based estimate (same technique used for View Path\'s approximate markers). Sorted worst-coverage-first.</p>' +
'<div id="areasPositionGaps">' + gapsTableHtml(false) + '</div>' +
unpositionedNote +
'</div>';
wireCollapseToggle('areasDensity', 'data-areas-density-toggle', densityTableHtml);
wireCollapseToggle('areasBridgeNodes', 'data-areas-bridge-toggle', bridgeTableHtml);
wireCollapseToggle('areasPositionGaps', 'data-areas-gaps-toggle', gapsTableHtml);
} catch (e) {
el.innerHTML = '<div class="text-center" style="color:var(--status-red);padding:20px">Failed to load area analytics: ' + esc(String(e)) + '</div>';
}
}
// #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
+1
View File
@@ -77,6 +77,7 @@ node test-packet-path-map.js
node test-ping-scores.js
node test-observer-neighbors-report-badge.js
node test-observer-direct-neighbors-panel.js
node test-analytics-areas-tab.js
echo ""
echo "═══════════════════════════════════════"
+306
View File
@@ -0,0 +1,306 @@
/**
* DOM-rendering tests for the "Areas" Analytics tab
* (renderAreasTab, public/analytics.js).
*
* Drives the real render function against a stubbed api() that returns a
* fixed /api/analytics/areas response, asserting rendered tables, empty
* states, and the 60s auto-refresh timer lifecycle — same harness style
* as test-analytics-wardriving-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: () => [] };
}
function makeAreasResponse(overrides) {
return Object.assign({
density: [
{ areaKey: 'ODE', label: 'Odense by', total: 5, active: 3, degraded: 1, silent: 1, roleCounts: { repeater: 2, client: 3 } },
{ areaKey: 'DK', label: 'Danmark (alle)', total: 8, active: 5, degraded: 2, silent: 1, roleCounts: { repeater: 3, client: 5 } },
],
bridgeNodes: [
{ publicKey: 'pkbridge01', name: 'BridgeNode', areaKey: 'ODE', label: 'Odense by', edgeCount: 4, otherAreaCount: 2, otherAreas: ['Danmark (alle)', 'Jylland'] },
],
positionGaps: [
{ areaKey: 'ODE', label: 'Odense by', realFix: 4, approximated: 1 },
],
unpositionedTotal: 3,
unpositionedNoNeighborFix: 1,
}, overrides);
}
function makeApiStub(resp) {
return function (path) {
if (path.indexOf('/analytics/areas') === 0) return Promise.resolve(resp);
return Promise.resolve({});
};
}
(async () => {
console.log('\n=== analytics.js: renderAreasTab ===');
await testAsync('renders the Node Density & Health table from the API response', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse()));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
assert.ok(el.innerHTML.includes('Node Density &amp; Health by Area'), 'density section heading should render');
assert.ok(el.innerHTML.includes('Odense by'), 'ODE area label should render');
assert.ok(el.innerHTML.includes('Danmark (alle)'), 'DK area label should render');
assert.ok(el.innerHTML.includes('client: 3, repeater: 2'), 'role mix should render sorted alphabetically');
});
await testAsync('renders the Cross-Area Bridge Nodes table with a node link and other-areas list', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse()));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
assert.ok(el.innerHTML.includes('Cross-Area Bridge Nodes'), 'bridge section heading should render');
assert.ok(el.innerHTML.includes('href="#/nodes/pkbridge01"'), 'bridge node should link to its node detail page');
assert.ok(el.innerHTML.includes('BridgeNode'), 'bridge node display name should render');
assert.ok(el.innerHTML.includes('Danmark (alle), Jylland'), 'other areas reached should be listed');
});
await testAsync('Node Density & Health sorts worst-health-first, not the API\'s Total-desc order', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({
density: [
{ areaKey: 'BIG', label: 'BigHealthy', total: 100, active: 100, degraded: 0, silent: 0, roleCounts: {} }, // 0% unhealthy, biggest
{ areaKey: 'SMALL', label: 'SmallSick', total: 4, active: 1, degraded: 1, silent: 2, roleCounts: {} }, // 75% unhealthy, smallest
{ areaKey: 'MID', label: 'MidSick', total: 10, active: 5, degraded: 3, silent: 2, roleCounts: {} }, // 50% unhealthy
],
})));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
const startIdx = el.innerHTML.indexOf('id="areasDensity"');
const section = el.innerHTML.slice(startIdx);
const idxSmall = section.indexOf('SmallSick');
const idxMid = section.indexOf('MidSick');
const idxBig = section.indexOf('BigHealthy');
assert.ok(idxSmall > -1 && idxMid > -1 && idxBig > -1, 'all three areas should render');
assert.ok(idxSmall < idxMid && idxMid < idxBig, 'rows should be ordered worst-health -> best-health, not by Total');
});
await testAsync('Node Density & Health collapses to top 10 with a "Show all" toggle', async () => {
const manyAreas = [];
for (let i = 0; i < 15; i++) manyAreas.push({ areaKey: 'A' + i, label: 'Area' + i, total: 10, active: 10 - i, degraded: 0, silent: i, roleCounts: {} });
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({ density: manyAreas })));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
const startIdx = el.innerHTML.indexOf('id="areasDensity"');
const section = el.innerHTML.slice(startIdx, el.innerHTML.indexOf('id="areasBridgeNodes"'));
const tbody = section.slice(section.indexOf('<tbody>'), section.indexOf('</tbody>'));
const rowCount = (tbody.match(/<tr>/g) || []).length;
assert.strictEqual(rowCount, 10, 'only 10 rows should render by default');
assert.ok(section.includes('Show all 15 areas'), 'a "Show all" toggle should appear when there are more than 10 areas');
});
await testAsync('Cross-Area Bridge Nodes keeps the API\'s otherAreaCount-desc order and collapses to top 10', async () => {
const manyBridges = [];
for (let i = 0; i < 12; i++) manyBridges.push({ publicKey: 'pk' + i, name: 'Bridge' + i, areaKey: 'A', label: 'AreaA', edgeCount: 5, otherAreaCount: 12 - i, otherAreas: ['AreaB'] });
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({ bridgeNodes: manyBridges })));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
const startIdx = el.innerHTML.indexOf('id="areasBridgeNodes"');
const section = el.innerHTML.slice(startIdx, el.innerHTML.indexOf('id="areasPositionGaps"'));
const idxFirst = section.indexOf('Bridge0');
const idxLast = section.indexOf('Bridge9');
assert.ok(idxFirst > -1 && idxFirst < idxLast, 'highest otherAreaCount (Bridge0) should render before a lower one (Bridge9)');
const tbody = section.slice(section.indexOf('<tbody>'), section.indexOf('</tbody>'));
const rowCount = (tbody.match(/<tr>/g) || []).length;
assert.strictEqual(rowCount, 10, 'only 10 rows should render by default');
assert.ok(section.includes('Show all 12 nodes'), 'a "Show all" toggle should appear when there are more than 10 bridge nodes');
});
await testAsync('renders the Position-Fix Coverage Gaps table with a computed percentage', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse()));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
assert.ok(el.innerHTML.includes('Position-Fix Coverage Gaps by Area'), 'position gaps section heading should render');
// 1 approximated of (4 real + 1 approximated) = 20.0%
assert.ok(el.innerHTML.includes('20.0%'), 'ODE row should show 20.0% estimated');
});
await testAsync('Position-Fix Coverage Gaps sorts worst-coverage-first, not the API\'s realFix-desc order', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({
positionGaps: [
{ areaKey: 'FULL', label: 'FullyMapped', realFix: 100, approximated: 0 }, // 0% estimated, biggest realFix
{ areaKey: 'WORST', label: 'WorstCoverage', realFix: 2, approximated: 8 }, // 80% estimated, smallest realFix
{ areaKey: 'MID', label: 'MidCoverage', realFix: 10, approximated: 5 }, // ~33% estimated
],
})));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
const startIdx = el.innerHTML.indexOf('id="areasPositionGaps"');
const section = el.innerHTML.slice(startIdx);
const idxWorst = section.indexOf('WorstCoverage');
const idxMid = section.indexOf('MidCoverage');
const idxFull = section.indexOf('FullyMapped');
assert.ok(idxWorst > -1 && idxMid > -1 && idxFull > -1, 'all three areas should render');
assert.ok(idxWorst < idxMid && idxMid < idxFull, 'rows should be ordered worst-% -> best-%, not by realFix');
});
await testAsync('Position-Fix Coverage Gaps collapses to top 10 with a "Show all" toggle', async () => {
const manyGaps = [];
for (let i = 0; i < 15; i++) manyGaps.push({ areaKey: 'A' + i, label: 'Area' + i, realFix: 10, approximated: i });
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({ positionGaps: manyGaps })));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
const startIdx = el.innerHTML.indexOf('id="areasPositionGaps"');
const section = el.innerHTML.slice(startIdx, el.innerHTML.indexOf('nodes network-wide have no real GPS fix'));
const tbody = section.slice(section.indexOf('<tbody>'), section.indexOf('</tbody>'));
const rowCount = (tbody.match(/<tr>/g) || []).length;
assert.strictEqual(rowCount, 10, 'only 10 rows should render by default');
assert.ok(section.includes('Show all 15 areas'), 'a "Show all" toggle should appear when there are more than 10 areas');
});
await testAsync('no "Show all" toggle on the Position-Fix Coverage Gaps table when there are 10 or fewer areas', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse()));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
assert.ok(!el.innerHTML.includes('Show all'), 'no toggle should render with only 1 area in positionGaps');
});
await testAsync('renders the unpositioned-nodes summary note including the no-neighbor-fix subset', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse()));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
assert.ok(el.innerHTML.includes('3 nodes network-wide have no real GPS fix'), 'unpositioned total should render');
assert.ok(el.innerHTML.includes('of which 1 also have no positioned neighbor'), 'no-neighbor-fix subset should render');
});
await testAsync('shows a neutral empty state when no Areas are configured', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub({ density: [], bridgeNodes: [], positionGaps: [], unpositionedTotal: 0, unpositionedNoNeighborFix: 0 }));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
assert.ok(el.innerHTML.includes('No Areas are configured'), 'should show the no-areas-configured empty state');
});
await testAsync('shows a neutral message when bridgeNodes is empty but other sections have data', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse({ bridgeNodes: [] })));
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
assert.ok(el.innerHTML.includes('No packet-derived neighbor edges cross between two different areas yet'), 'should show the bridge-nodes empty state');
});
await testAsync('shows a friendly message on API failure instead of throwing', async () => {
const ctx = makeAnalyticsSandbox(function () { return Promise.reject(new Error('network down')); });
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(el);
assert.ok(el.innerHTML.includes('Failed to load area analytics'), 'should show a failure message');
assert.ok(el.innerHTML.includes('network down'), 'should include the underlying error');
});
await testAsync('rendering registers a real interval, and stop() actually clears it (not a no-op)', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeAreasResponse()));
const stop = ctx.window._analyticsStopAreasRefresh;
assert.strictEqual(typeof stop, 'function', '_stopAreasRefresh must be exported for testing/cleanup');
stop(); // must not throw when no timer is registered yet
const el = fakeEl();
await ctx.window._analyticsRenderAreasTab(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(` Areas tab: ${passed} passed, ${failed} failed`);
console.log('════════════════════════════════════════');
process.exit(failed === 0 ? 0 : 1);
})();