mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 06:08:03 +00:00
Merge branch 'areas-meshguide-sync'
This commit is contained in:
@@ -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}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 & 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
|
||||
|
||||
@@ -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 "═══════════════════════════════════════"
|
||||
|
||||
@@ -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 & 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);
|
||||
})();
|
||||
Reference in New Issue
Block a user