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..5bf20317 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,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 = '
No positioned nodes fall inside any configured area.
', 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 'No packet-derived neighbor edges cross between two different areas yet.
', 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 '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.' : '.') + + '
'; + + el.innerHTML = + '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.
' + + '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.
' + + '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.
' + + '