From 1e01d04c8af417ef9f024f721a0e4214fa332d63 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 27 Jul 2026 11:54:25 +0200 Subject: [PATCH 1/3] feat: Area analytics -- node density/health, cross-area bridge nodes, position-fix coverage gaps New GET /api/analytics/areas endpoint and "Areas" analytics tab, built on the configured drawn-polygon Areas (meshguide.dk sync), distinct from hashRegion scope adoption: - Density: node count + active/degraded/silent health + role mix per area, multi-membership via AreaKeysForPoint (a node in a sub-area also rolls up into its parent region). - Bridge nodes: which nodes' packet-derived neighbor_edges reach into another area, ranked by how many other areas they touch -- distinct from the network-wide, area-unaware bridge_score. - Position gaps: per area, real GPS fix vs. neighbor-centroid-estimated position, reusing nearestPositionedNeighbor (the same technique View Path's approx markers use) purely as an internal coverage signal. 30s TTL cache on the handler since position-gap computation calls nearestPositionedNeighbor once per unpositioned node. Co-Authored-By: Claude Sonnet 5 --- cmd/server/area_analytics_test.go | 218 ++++++++++++++++++++++++++ cmd/server/db.go | 245 ++++++++++++++++++++++++++++++ cmd/server/openapi.go | 49 ++++++ cmd/server/routes.go | 62 ++++++++ cmd/server/types.go | 67 ++++++++ public/analytics.js | 123 ++++++++++++++- test-all.sh | 1 + test-analytics-areas-tab.js | 216 ++++++++++++++++++++++++++ 8 files changed, 980 insertions(+), 1 deletion(-) create mode 100644 cmd/server/area_analytics_test.go create mode 100644 test-analytics-areas-tab.js diff --git a/cmd/server/area_analytics_test.go b/cmd/server/area_analytics_test.go new file mode 100644 index 00000000..f8b59842 --- /dev/null +++ b/cmd/server/area_analytics_test.go @@ -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} +} diff --git a/cmd/server/db.go b/cmd/server/db.go index d00ad04a..a29271c1 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -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 { diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index b1a37940..50c961f9 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -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."}, + }, + }, } } diff --git a/cmd/server/routes.go b/cmd/server/routes.go index bfb6be1f..e6bb86b3 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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 { diff --git a/cmd/server/types.go b/cmd/server/types.go index 0e7c59f9..f2fc8c52 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -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"` diff --git a/public/analytics.js b/public/analytics.js index b469d684..843fbe34 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -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 @@ + @@ -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,118 @@ 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 = '
Loading area analytics…
'; + 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 = '
No Areas are configured — this tab needs at least one drawn-polygon Area (meshguide.dk sync) to report on.
'; + return; + } + + function pct(n, total) { + if (!total) return '—'; + return (n / total * 100).toFixed(1) + '%'; + } + + var densityRows = density.map(function (a) { + var roleParts = Object.keys(a.roleCounts || {}).sort().map(function (r) { + return r + ': ' + a.roleCounts[r]; + }).join(', '); + return '' + + '' + esc(a.label) + '' + + '' + a.total.toLocaleString() + '' + + '' + a.active.toLocaleString() + '' + + '' + a.degraded.toLocaleString() + '' + + '' + a.silent.toLocaleString() + '' + + '' + esc(roleParts) + '' + + ''; + }).join(''); + var densityHtml = density.length ? + '' + + '' + + '' + densityRows + '
AreaTotalActiveDegradedSilentRole Mix
' : + '

No positioned nodes fall inside any configured area.

'; + + var bridgeRows = bridgeNodes.map(function (b) { + return '' + + '' + esc(b.name) + '' + + '' + esc(b.label) + '' + + '' + b.edgeCount.toLocaleString() + '' + + '' + b.otherAreaCount.toLocaleString() + '' + + '' + (b.otherAreas || []).map(esc).join(', ') + '' + + ''; + }).join(''); + var bridgeHtml = bridgeNodes.length ? + '' + + '' + + '' + bridgeRows + '
NodeHome AreaCross-Area EdgesOther Areas ReachedWhich Areas
' : + '

No packet-derived neighbor edges cross between two different areas yet.

'; + + var gapRows = positionGaps.map(function (g) { + var total = g.realFix + g.approximated; + return '' + + '' + esc(g.label) + '' + + '' + g.realFix.toLocaleString() + '' + + '' + g.approximated.toLocaleString() + '' + + '' + pct(g.approximated, total) + '' + + ''; + }).join(''); + var gapHtml = positionGaps.length ? + '' + + '' + + '' + gapRows + '
AreaReal GPS FixEstimated (via Neighbors)% Estimated
' : + '

No unpositioned node could be placed in any area, even approximately.

'; + var unpositionedNote = '

' + + (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.' : '.') + + '

'; + + el.innerHTML = + '
' + + '

Node Density & Health by Area

' + + '

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).

' + + densityHtml + + '
' + + '
' + + '

Cross-Area Bridge Nodes

' + + '

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.

' + + bridgeHtml + + '
' + + '
' + + '

Position-Fix Coverage Gaps by Area

' + + '

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).

' + + gapHtml + + unpositionedNote + + '
'; + } catch (e) { + el.innerHTML = '
Failed to load area analytics: ' + esc(String(e)) + '
'; + } + } + // #1085 — Roles tab (folded in from former /#/roles page). // Renders distribution of node roles + per-role clock-skew posture. // Auto-refreshes every 60s while the Roles tab is active (matches the diff --git a/test-all.sh b/test-all.sh index ea7125b3..51af8f0d 100755 --- a/test-all.sh +++ b/test-all.sh @@ -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 "═══════════════════════════════════════" diff --git a/test-analytics-areas-tab.js b/test-analytics-areas-tab.js new file mode 100644 index 00000000..b8599797 --- /dev/null +++ b/test-analytics-areas-tab.js @@ -0,0 +1,216 @@ +/** + * 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('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('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); +})(); From b096f73662a55646c02303e315937b094041a3cd Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 28 Jul 2026 04:48:39 +0200 Subject: [PATCH 2/3] fix: Areas tab Position-Fix Coverage Gaps table was overwhelming -- sort worst-first, collapse to top 10 dborup: the full 36-row dump buried the handful of areas with an actual gap behind dozens already fully GPS-mapped (0% estimated). Sort by % estimated descending and collapse to top 10 with a "Show all" toggle, matching the existing pattern from the Wardriving tab's Top Senders / Coverage by Observer sections. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 68 +++++++++++++++++++++++++++++-------- test-analytics-areas-tab.js | 40 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 843fbe34..e0f384a0 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -6586,20 +6586,42 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '' + bridgeRows + '' : '

No packet-derived neighbor edges cross between two different areas yet.

'; - var gapRows = positionGaps.map(function (g) { - var total = g.realFix + g.approximated; - return '' + - '' + esc(g.label) + '' + - '' + g.realFix.toLocaleString() + '' + - '' + g.approximated.toLocaleString() + '' + - '' + pct(g.approximated, total) + '' + - ''; - }).join(''); - var gapHtml = positionGaps.length ? - '' + + // 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. Collapses to the top + // 10 by default, same "Show all N" pattern as the Wardriving tab's + // Top Senders/Coverage by Observer sections. + var AREAS_TOP_N = 10; + 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 gapsToggleHtml(expanded) { + if (sortedGaps.length <= AREAS_TOP_N) return ''; + var label = expanded ? 'Show fewer' : 'Show all ' + sortedGaps.length.toLocaleString() + ' areas'; + return '
'; + } + function gapsTableHtml(expanded) { + if (!sortedGaps.length) { + return '

No unpositioned node could be placed in any area, even approximately.

'; + } + var shown = expanded ? sortedGaps : sortedGaps.slice(0, AREAS_TOP_N); + var rows = shown.map(function (g) { + var total = g.realFix + g.approximated; + return '' + + '' + + '' + + '' + + '' + + ''; + }).join(''); + return '
' + esc(g.label) + '' + g.realFix.toLocaleString() + '' + g.approximated.toLocaleString() + '' + pct(g.approximated, total) + '
' + '' + - '' + gapRows + '
AreaReal GPS FixEstimated (via Neighbors)% Estimated
' : - '

No unpositioned node could be placed in any area, even approximately.

'; + '' + rows + '' + gapsToggleHtml(expanded); + } var unpositionedNote = '

' + (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.' : '.') + @@ -6618,10 +6640,26 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '' + '

' + '

Position-Fix Coverage Gaps by Area

' + - '

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).

' + - gapHtml + + '

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.

' + + '
' + gapsTableHtml(false) + '
' + unpositionedNote + '
'; + + var gapsExpanded = false; + var gapsContainer = document.getElementById('areasPositionGaps'); + function attachGapsToggle() { + var container = document.getElementById('areasPositionGaps'); + if (!container) return; + var btn = container.querySelector('[data-areas-gaps-toggle]'); + if (btn) { + btn.addEventListener('click', function () { + gapsExpanded = !gapsExpanded; + container.innerHTML = gapsTableHtml(gapsExpanded); + attachGapsToggle(); + }); + } + } + if (gapsContainer) attachGapsToggle(); } catch (e) { el.innerHTML = '
Failed to load area analytics: ' + esc(String(e)) + '
'; } diff --git a/test-analytics-areas-tab.js b/test-analytics-areas-tab.js index b8599797..4707564f 100644 --- a/test-analytics-areas-tab.js +++ b/test-analytics-areas-tab.js @@ -164,6 +164,46 @@ function makeApiStub(resp) { 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(''), section.indexOf('')); + const rowCount = (tbody.match(//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(); From 4491167a603ea6e5ea363b1c1652ddcd9526528f Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 28 Jul 2026 04:53:29 +0200 Subject: [PATCH 3/3] feat: extend collapse-to-top-10 pattern to Density and Bridge Nodes sections in Areas tab dborup asked to apply the same treatment given to Position-Fix Coverage Gaps to the other two Areas sections. Node Density & Health now sorts worst-health-first (% degraded+silent descending) instead of the API's raw Total-desc order, so small struggling areas surface instead of being buried under Europa/Danmark. Cross-Area Bridge Nodes keeps its existing otherAreaCount-desc order (already most-interesting-first) and just gets the same collapse-to-10 treatment. Factored the three sections' collapsible-table rendering/wiring into shared helpers. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 150 ++++++++++++++++++++++-------------- test-analytics-areas-tab.js | 50 ++++++++++++ 2 files changed, 141 insertions(+), 59 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index e0f384a0..5bf20317 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -6552,7 +6552,59 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf return (n / total * 100).toFixed(1) + '%'; } - var densityRows = density.map(function (a) { + // 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 + ? '
' + : ''; + return '' + theadHtml + '' + rows + '
' + 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(', '); @@ -6564,14 +6616,17 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '' + a.silent.toLocaleString() + '' + '' + esc(roleParts) + '' + ''; - }).join(''); - var densityHtml = density.length ? - '' + - '' + - '' + densityRows + '
AreaTotalActiveDegradedSilentRole Mix
' : - '

No positioned nodes fall inside any configured area.

'; + } + var densityThead = 'AreaTotalActiveDegradedSilentRole Mix'; + function densityTableHtml(expanded) { + return collapsibleTableHtml(sortedDensity, densityThead, densityRowHtml, 'data-areas-density-toggle', 'areas', + '

No positioned nodes fall inside any configured area.

', expanded); + } - var bridgeRows = bridgeNodes.map(function (b) { + // 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 '' + '' + esc(b.name) + '' + '' + esc(b.label) + '' + @@ -6579,49 +6634,38 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '' + b.otherAreaCount.toLocaleString() + '' + '' + (b.otherAreas || []).map(esc).join(', ') + '' + ''; - }).join(''); - var bridgeHtml = bridgeNodes.length ? - '' + - '' + - '' + bridgeRows + '
NodeHome AreaCross-Area EdgesOther Areas ReachedWhich Areas
' : - '

No packet-derived neighbor edges cross between two different areas yet.

'; + } + var bridgeThead = 'NodeHome AreaCross-Area EdgesOther Areas ReachedWhich Areas'; + function bridgeTableHtml(expanded) { + return collapsibleTableHtml(bridgeNodes, bridgeThead, bridgeRowHtml, 'data-areas-bridge-toggle', 'nodes', + '

No packet-derived neighbor edges cross between two different areas yet.

', expanded); + } - // 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. Collapses to the top - // 10 by default, same "Show all N" pattern as the Wardriving tab's - // Top Senders/Coverage by Observer sections. - var AREAS_TOP_N = 10; + // 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 gapsToggleHtml(expanded) { - if (sortedGaps.length <= AREAS_TOP_N) return ''; - var label = expanded ? 'Show fewer' : 'Show all ' + sortedGaps.length.toLocaleString() + ' areas'; - return '
'; + function gapsRowHtml(g) { + var total = g.realFix + g.approximated; + return '' + + '' + esc(g.label) + '' + + '' + g.realFix.toLocaleString() + '' + + '' + g.approximated.toLocaleString() + '' + + '' + pct(g.approximated, total) + '' + + ''; } + var gapsThead = 'AreaReal GPS FixEstimated (via Neighbors)% Estimated'; function gapsTableHtml(expanded) { - if (!sortedGaps.length) { - return '

No unpositioned node could be placed in any area, even approximately.

'; - } - var shown = expanded ? sortedGaps : sortedGaps.slice(0, AREAS_TOP_N); - var rows = shown.map(function (g) { - var total = g.realFix + g.approximated; - return '' + - '' + esc(g.label) + '' + - '' + g.realFix.toLocaleString() + '' + - '' + g.approximated.toLocaleString() + '' + - '' + pct(g.approximated, total) + '' + - ''; - }).join(''); - return '' + - '' + - '' + rows + '
AreaReal GPS FixEstimated (via Neighbors)% Estimated
' + gapsToggleHtml(expanded); + return collapsibleTableHtml(sortedGaps, gapsThead, gapsRowHtml, 'data-areas-gaps-toggle', 'areas', + '

No unpositioned node could be placed in any area, even approximately.

', expanded); } + var unpositionedNote = '

' + (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.' : '.') + @@ -6630,13 +6674,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf el.innerHTML = '

' + '

Node Density & Health by Area

' + - '

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).

' + - densityHtml + + '

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.

' + + '
' + densityTableHtml(false) + '
' + '
' + '
' + '

Cross-Area Bridge Nodes

' + '

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.

' + - bridgeHtml + + '
' + bridgeTableHtml(false) + '
' + '
' + '
' + '

Position-Fix Coverage Gaps by Area

' + @@ -6645,21 +6689,9 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf unpositionedNote + '
'; - var gapsExpanded = false; - var gapsContainer = document.getElementById('areasPositionGaps'); - function attachGapsToggle() { - var container = document.getElementById('areasPositionGaps'); - if (!container) return; - var btn = container.querySelector('[data-areas-gaps-toggle]'); - if (btn) { - btn.addEventListener('click', function () { - gapsExpanded = !gapsExpanded; - container.innerHTML = gapsTableHtml(gapsExpanded); - attachGapsToggle(); - }); - } - } - if (gapsContainer) attachGapsToggle(); + 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 = '
Failed to load area analytics: ' + esc(String(e)) + '
'; } diff --git a/test-analytics-areas-tab.js b/test-analytics-areas-tab.js index 4707564f..843b17fd 100644 --- a/test-analytics-areas-tab.js +++ b/test-analytics-areas-tab.js @@ -155,6 +155,56 @@ function makeApiStub(resp) { 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(''), section.indexOf('')); + const rowCount = (tbody.match(//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(''), section.indexOf('')); + const rowCount = (tbody.match(//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();