From f479e75cc64bfdbf01ef143c6a01d30e4f8e87b4 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 28 Jul 2026 14:13:04 +0200 Subject: [PATCH 1/3] feat: add Area Activity leaderboard to Ping Scores Ranks configured areas by number of distinct pings with a relay hop or hearing station inside them -- "which area is most active" -- reusing the same touched-areas config/matching View Path already draws from (annotatePacketPathTouchedAreas), rather than reimplementing area matching. Omitted entirely on deployments with no areas configured. --- cmd/server/openapi.go | 1 + cmd/server/ping_scores.go | 41 ++++++++++++++++++---- cmd/server/ping_scores_test.go | 63 ++++++++++++++++++++++++++++++++++ public/ping-scores.js | 7 ++++ test-ping-scores.js | 26 ++++++++++++-- 5 files changed, 129 insertions(+), 9 deletions(-) diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index cbe61806..5f726b16 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -460,6 +460,7 @@ func componentSchemas() map[string]interface{} { "relayLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top nodes ranked by number of distinct pings they appeared as a relay hop in (deduped per ping first, so one busy ping's many branches can't over-credit a relay)."}, "observerLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top observers ranked by number of pings they were the first station to hear."}, "senderLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top senders ranked by number of pings sent in the last 30 days (unlike the other leaderboards and records, which are all-time). Keyed by the sender display name from the channel message itself -- no resolved pubkey, so entries never carry one."}, + "areaLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Configured areas ranked by number of distinct pings with at least one relay hop or hearing station inside them -- \"which area is most active,\" all-time. Same area-matching config as View Path's touchedAreas. Keyed by area label -- no resolved pubkey, so entries never carry one. Omitted entirely when no areas are configured."}, }, }, "AreaDensity": map[string]interface{}{ diff --git a/cmd/server/ping_scores.go b/cmd/server/ping_scores.go index 68eeee33..07751142 100644 --- a/cmd/server/ping_scores.go +++ b/cmd/server/ping_scores.go @@ -60,11 +60,13 @@ type PingScore struct { // AirtimeMs (with RelayCount>0) are available. KmPerSecondAirtime *float64 `json:"kmPerSecondAirtime,omitempty"` - // relayPubkeys/firstPubkey/firstName feed the leaderboards during - // computeAllPingScores -- never serialized on an individual record. - relayPubkeys []string - firstPubkey string - firstName string + // relayPubkeys/firstPubkey/firstName/touchedAreaLabels feed the + // leaderboards during computeAllPingScores -- never serialized on an + // individual record. + relayPubkeys []string + firstPubkey string + firstName string + touchedAreaLabels []string } // PingLeaderboardEntry is one row of a leaderboard ranking. @@ -110,6 +112,16 @@ type PingScoresSnapshot struct { // pubkey to link back to a node, so entries never carry one (matches // PingLeaderboardEntry.Pubkey's existing omitempty). SenderLeaderboard []PingLeaderboardEntry `json:"senderLeaderboard,omitempty"` + + // AreaLeaderboard ranks configured areas (cmd/server/config.go's + // AreaEntry, same set View Path's touchedAreas draws from) by how + // many DISTINCT pings had at least one relay hop or hearing station + // inside them -- "which area is most active." Keyed by area Label + // (matching SenderLeaderboard's no-pubkey precedent -- an area isn't a + // resolvable node either). Omitted entirely when no areas are + // configured (per-deployment optional feature, like the rest of the + // Areas tooling). + AreaLeaderboard []PingLeaderboardEntry `json:"areaLeaderboard,omitempty"` } // WeeklyPingRecords mirrors PingScoresSnapshot's 5 all-time record slots, @@ -157,14 +169,16 @@ func (db *DB) fetchPingTriggers() ([]pingTriggerRow, error) { } // computePingScore builds one ping's full stats via the same GetPacketPath -// + airtime-annotation path View Path uses, so the numbers on the -// highscore board always match what "View path" shows for that packet. +// + airtime/touched-areas annotation path View Path uses, so the numbers +// on the highscore board always match what "View path" shows for that +// packet. func (s *Server) computePingScore(trigger pingTriggerRow) *PingScore { resp, err := s.db.GetPacketPath(trigger.hash) if err != nil || resp == nil || len(resp.Branches) == 0 { return nil } s.annotatePacketPathAirtime(resp) + s.annotatePacketPathTouchedAreas(resp) score := &PingScore{ Hash: trigger.hash, @@ -225,6 +239,9 @@ func (s *Server) computePingScore(trigger pingTriggerRow) *PingScore { score.firstPubkey = resp.First.Observer.PublicKey score.firstName = resp.First.Observer.Name } + for _, area := range resp.TouchedAreas { + score.touchedAreaLabels = append(score.touchedAreaLabels, area.Label) + } return score } @@ -286,6 +303,7 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { // elsewhere in this file's package. senderCutoff := time.Now().AddDate(0, 0, -30) senderCounts := map[string]*PingLeaderboardEntry{} + areaCounts := map[string]*PingLeaderboardEntry{} // weekCutoff drives ThisWeek -- same fail-toward-stale rule as // senderCutoff above (an unparseable timestamp is excluded, not @@ -330,6 +348,14 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { } e.Count++ } + for _, label := range score.touchedAreaLabels { + e := areaCounts[label] + if e == nil { + e = &PingLeaderboardEntry{Name: label} + areaCounts[label] = e + } + e.Count++ + } } snap.FarthestPing = allTime.Farthest @@ -366,6 +392,7 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { snap.RelayLeaderboard = topPingLeaderboard(relayCounts, 10) snap.ObserverLeaderboard = topPingLeaderboard(observerCounts, 10) snap.SenderLeaderboard = topPingLeaderboard(senderCounts, 10) + snap.AreaLeaderboard = topPingLeaderboard(areaCounts, 10) return snap } diff --git a/cmd/server/ping_scores_test.go b/cmd/server/ping_scores_test.go index 23a38c0a..af718855 100644 --- a/cmd/server/ping_scores_test.go +++ b/cmd/server/ping_scores_test.go @@ -375,6 +375,69 @@ func TestComputeAllPingScores_ThisWeekNilWhenNoRecentPings(t *testing.T) { } } +// TestComputeAllPingScores_AreaLeaderboard confirms AreaLeaderboard tallies +// DISTINCT pings per configured area (a ping with two hearing stations in +// the SAME area still counts once for it, mirroring RelayLeaderboard's +// per-ping dedup), correctly attributes a ping that touches two areas to +// BOTH, and reuses the exact same area-matching config View Path's +// touchedAreas draws from. +func TestComputeAllPingScores_AreaLeaderboard(t *testing.T) { + srv, _ := setupPingScoresFixture(t) + + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "AREAA": {Label: "Area A", LatMin: f(55.9), LatMax: f(56.2), LonMin: f(9.9), LonMax: f(10.2)}, + "AREAB": {Label: "Area B", LatMin: f(57.7), LatMax: f(57.9), LonMin: f(12.5), LonMax: f(12.7)}, + } + + // Ping 1: pingobsa AND pingobsc both fall in Area A (must count once + // for Area A, not twice), pingobsb falls in Area B -- touches both. + tx1 := seedPingTrigger(t, srv, "arealb0000001", "#test", "Alice", "2026-01-15T10:00:00Z") + seedPingObservation(t, srv, tx1, "pingobsa", 9.0, `[]`, `[]`, 1736935200) + seedPingObservation(t, srv, tx1, "pingobsc", 6.0, `["aa"]`, `["pkrelay1"]`, 1736935210) + seedPingObservation(t, srv, tx1, "pingobsb", 4.0, `["aa","bb"]`, `["pkrelay1","pkrelay2"]`, 1736935260) + + // Ping 2: only pingobsa (Area A) -- Area B must NOT get credit for this one. + tx2 := seedPingTrigger(t, srv, "arealb0000002", "#test", "Bob", "2026-01-15T11:00:00Z") + seedPingObservation(t, srv, tx2, "pingobsa", 9.0, `[]`, `[]`, 1736938800) + + snap := srv.computeAllPingScores() + if snap == nil { + t.Fatal("computeAllPingScores returned nil") + } + counts := map[string]int{} + for _, e := range snap.AreaLeaderboard { + counts[e.Name] = e.Count + } + if counts["Area A"] != 2 { + t.Errorf("Area A count = %d, want 2 (both pings touch it, dedup within ping 1's two same-area stations)", counts["Area A"]) + } + if counts["Area B"] != 1 { + t.Errorf("Area B count = %d, want 1 (only ping 1 touches it)", counts["Area B"]) + } +} + +// TestComputeAllPingScores_AreaLeaderboardNoAreasConfigured confirms +// AreaLeaderboard stays nil (not present) rather than an empty-but-present +// array when the deployment has no areas configured -- matches +// annotatePacketPathTouchedAreas's own early-return for the same case. +func TestComputeAllPingScores_AreaLeaderboardNoAreasConfigured(t *testing.T) { + srv, _ := setupPingScoresFixture(t) + srv.cfg.Areas = nil + + txID := seedPingTrigger(t, srv, "arealbnone0001", "#test", "Alice", "2026-01-15T10:00:00Z") + seedPingObservation(t, srv, txID, "pingobsa", 9.0, `[]`, `[]`, 1736935200) + seedPingObservation(t, srv, txID, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, 1736935210) + + snap := srv.computeAllPingScores() + if snap == nil { + t.Fatal("computeAllPingScores returned nil") + } + if snap.AreaLeaderboard != nil { + t.Errorf("AreaLeaderboard = %+v, want nil when no areas are configured", snap.AreaLeaderboard) + } +} + // TestHandlePingScores_EmptyState confirms the endpoint returns a // well-formed 200 with zero-valued/omitted fields rather than an error // when no ping has ever been recorded -- an ordinary state, not a failure. diff --git a/public/ping-scores.js b/public/ping-scores.js index 838d3586..1533a60e 100644 --- a/public/ping-scores.js +++ b/public/ping-scores.js @@ -143,6 +143,13 @@ leaderboardTableHtml('Top Senders (30 days)', phIcon('megaphone'), data.senderLeaderboard, 'Sender') + leaderboardTableHtml('Top Relays', phIcon('repeat'), data.relayLeaderboard) + leaderboardTableHtml('Top First-Hearers', phIcon('eye'), data.observerLeaderboard) + + // areaLeaderboard is omitted entirely (not an empty array) when the + // deployment has no areas configured (cmd/server/ping_scores.go) -- + // skip the whole card rather than show an always-empty "Area + // Activity" section on every deployment without areas set up. + (data.areaLeaderboard && data.areaLeaderboard.length + ? leaderboardTableHtml('Area Activity', phIcon('map-pin'), data.areaLeaderboard, 'Area') + : '') + '' + ''; diff --git a/test-ping-scores.js b/test-ping-scores.js index bfc2d826..17eb83bb 100644 --- a/test-ping-scores.js +++ b/test-ping-scores.js @@ -130,6 +130,7 @@ function makeSandbox(apiImpl) { relayLeaderboard: [{ pubkey: 'pkrelay1', name: 'RelayOne', count: 7 }], observerLeaderboard: [{ pubkey: 'pkobs1', name: 'ObsOne', count: 3 }], senderLeaderboard: [{ name: 'PingMaster', count: 12 }], + areaLeaderboard: [{ name: 'Area A', count: 9 }], }; const { container, getPage } = makeSandbox(() => Promise.resolve(data)); await getPage().init(container); @@ -144,9 +145,30 @@ function makeSandbox(apiImpl) { assert.ok(container.innerHTML.includes('All-Time Records'), 'should show the All-Time Records section heading, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('12.3'), 'should show the thisWeek farthest record km distinct from the all-time one, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('WeeklyRepeater'), 'should show the thisWeek record\'s node name, got: ' + container.innerHTML); + assert.ok(container.innerHTML.includes('Area Activity'), 'should show the Area Activity leaderboard heading, got: ' + container.innerHTML); + assert.ok(container.innerHTML.includes('Area A'), 'should show the area leaderboard entry, got: ' + container.innerHTML); passed++; - console.log(' ✅ renders all 5 records and all three leaderboards (including Top Senders) from a populated response'); - } catch (e) { failed++; console.log(' ❌ renders all 5 records and all three leaderboards (including Top Senders) from a populated response: ' + e.message); } + console.log(' ✅ renders all 5 records and all four leaderboards (including Top Senders and Area Activity) from a populated response'); + } catch (e) { failed++; console.log(' ❌ renders all 5 records and all four leaderboards (including Top Senders and Area Activity) from a populated response: ' + e.message); } + })(); + + await (async () => { + try { + // areaLeaderboard is omitted entirely (not an empty array) when the + // deployment has no areas configured -- the whole "Area Activity" + // card must be skipped, not shown empty/broken, on deployments that + // never set up areas. + const data = { + totalPings: 1, + generatedAt: new Date().toISOString(), + relayLeaderboard: [{ pubkey: 'pkrelay1', name: 'RelayOne', count: 7 }], + }; + const { container, getPage } = makeSandbox(() => Promise.resolve(data)); + await getPage().init(container); + assert.ok(!container.innerHTML.includes('Area Activity'), 'should NOT show an Area Activity card when areaLeaderboard is absent, got: ' + container.innerHTML); + passed++; + console.log(' ✅ Area Activity leaderboard card is skipped entirely when no areas are configured'); + } catch (e) { failed++; console.log(' ❌ Area Activity leaderboard card is skipped entirely when no areas are configured: ' + e.message); } })(); await (async () => { From dc8a263d308d1f29e57ac8f21adb15762eee0de5 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 28 Jul 2026 14:20:16 +0200 Subject: [PATCH 2/3] Revert "feat: add Area Activity leaderboard to Ping Scores" This reverts commit f479e75cc64bfdbf01ef143c6a01d30e4f8e87b4. --- cmd/server/openapi.go | 1 - cmd/server/ping_scores.go | 41 ++++------------------ cmd/server/ping_scores_test.go | 63 ---------------------------------- public/ping-scores.js | 7 ---- test-ping-scores.js | 26 ++------------ 5 files changed, 9 insertions(+), 129 deletions(-) diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 5f726b16..cbe61806 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -460,7 +460,6 @@ func componentSchemas() map[string]interface{} { "relayLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top nodes ranked by number of distinct pings they appeared as a relay hop in (deduped per ping first, so one busy ping's many branches can't over-credit a relay)."}, "observerLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top observers ranked by number of pings they were the first station to hear."}, "senderLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Top senders ranked by number of pings sent in the last 30 days (unlike the other leaderboards and records, which are all-time). Keyed by the sender display name from the channel message itself -- no resolved pubkey, so entries never carry one."}, - "areaLeaderboard": map[string]interface{}{"type": "array", "items": schemaRef("PingLeaderboardEntry"), "description": "Configured areas ranked by number of distinct pings with at least one relay hop or hearing station inside them -- \"which area is most active,\" all-time. Same area-matching config as View Path's touchedAreas. Keyed by area label -- no resolved pubkey, so entries never carry one. Omitted entirely when no areas are configured."}, }, }, "AreaDensity": map[string]interface{}{ diff --git a/cmd/server/ping_scores.go b/cmd/server/ping_scores.go index 07751142..68eeee33 100644 --- a/cmd/server/ping_scores.go +++ b/cmd/server/ping_scores.go @@ -60,13 +60,11 @@ type PingScore struct { // AirtimeMs (with RelayCount>0) are available. KmPerSecondAirtime *float64 `json:"kmPerSecondAirtime,omitempty"` - // relayPubkeys/firstPubkey/firstName/touchedAreaLabels feed the - // leaderboards during computeAllPingScores -- never serialized on an - // individual record. - relayPubkeys []string - firstPubkey string - firstName string - touchedAreaLabels []string + // relayPubkeys/firstPubkey/firstName feed the leaderboards during + // computeAllPingScores -- never serialized on an individual record. + relayPubkeys []string + firstPubkey string + firstName string } // PingLeaderboardEntry is one row of a leaderboard ranking. @@ -112,16 +110,6 @@ type PingScoresSnapshot struct { // pubkey to link back to a node, so entries never carry one (matches // PingLeaderboardEntry.Pubkey's existing omitempty). SenderLeaderboard []PingLeaderboardEntry `json:"senderLeaderboard,omitempty"` - - // AreaLeaderboard ranks configured areas (cmd/server/config.go's - // AreaEntry, same set View Path's touchedAreas draws from) by how - // many DISTINCT pings had at least one relay hop or hearing station - // inside them -- "which area is most active." Keyed by area Label - // (matching SenderLeaderboard's no-pubkey precedent -- an area isn't a - // resolvable node either). Omitted entirely when no areas are - // configured (per-deployment optional feature, like the rest of the - // Areas tooling). - AreaLeaderboard []PingLeaderboardEntry `json:"areaLeaderboard,omitempty"` } // WeeklyPingRecords mirrors PingScoresSnapshot's 5 all-time record slots, @@ -169,16 +157,14 @@ func (db *DB) fetchPingTriggers() ([]pingTriggerRow, error) { } // computePingScore builds one ping's full stats via the same GetPacketPath -// + airtime/touched-areas annotation path View Path uses, so the numbers -// on the highscore board always match what "View path" shows for that -// packet. +// + airtime-annotation path View Path uses, so the numbers on the +// highscore board always match what "View path" shows for that packet. func (s *Server) computePingScore(trigger pingTriggerRow) *PingScore { resp, err := s.db.GetPacketPath(trigger.hash) if err != nil || resp == nil || len(resp.Branches) == 0 { return nil } s.annotatePacketPathAirtime(resp) - s.annotatePacketPathTouchedAreas(resp) score := &PingScore{ Hash: trigger.hash, @@ -239,9 +225,6 @@ func (s *Server) computePingScore(trigger pingTriggerRow) *PingScore { score.firstPubkey = resp.First.Observer.PublicKey score.firstName = resp.First.Observer.Name } - for _, area := range resp.TouchedAreas { - score.touchedAreaLabels = append(score.touchedAreaLabels, area.Label) - } return score } @@ -303,7 +286,6 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { // elsewhere in this file's package. senderCutoff := time.Now().AddDate(0, 0, -30) senderCounts := map[string]*PingLeaderboardEntry{} - areaCounts := map[string]*PingLeaderboardEntry{} // weekCutoff drives ThisWeek -- same fail-toward-stale rule as // senderCutoff above (an unparseable timestamp is excluded, not @@ -348,14 +330,6 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { } e.Count++ } - for _, label := range score.touchedAreaLabels { - e := areaCounts[label] - if e == nil { - e = &PingLeaderboardEntry{Name: label} - areaCounts[label] = e - } - e.Count++ - } } snap.FarthestPing = allTime.Farthest @@ -392,7 +366,6 @@ func (s *Server) computeAllPingScores() *PingScoresSnapshot { snap.RelayLeaderboard = topPingLeaderboard(relayCounts, 10) snap.ObserverLeaderboard = topPingLeaderboard(observerCounts, 10) snap.SenderLeaderboard = topPingLeaderboard(senderCounts, 10) - snap.AreaLeaderboard = topPingLeaderboard(areaCounts, 10) return snap } diff --git a/cmd/server/ping_scores_test.go b/cmd/server/ping_scores_test.go index af718855..23a38c0a 100644 --- a/cmd/server/ping_scores_test.go +++ b/cmd/server/ping_scores_test.go @@ -375,69 +375,6 @@ func TestComputeAllPingScores_ThisWeekNilWhenNoRecentPings(t *testing.T) { } } -// TestComputeAllPingScores_AreaLeaderboard confirms AreaLeaderboard tallies -// DISTINCT pings per configured area (a ping with two hearing stations in -// the SAME area still counts once for it, mirroring RelayLeaderboard's -// per-ping dedup), correctly attributes a ping that touches two areas to -// BOTH, and reuses the exact same area-matching config View Path's -// touchedAreas draws from. -func TestComputeAllPingScores_AreaLeaderboard(t *testing.T) { - srv, _ := setupPingScoresFixture(t) - - f := func(v float64) *float64 { return &v } - srv.cfg.Areas = map[string]AreaEntry{ - "AREAA": {Label: "Area A", LatMin: f(55.9), LatMax: f(56.2), LonMin: f(9.9), LonMax: f(10.2)}, - "AREAB": {Label: "Area B", LatMin: f(57.7), LatMax: f(57.9), LonMin: f(12.5), LonMax: f(12.7)}, - } - - // Ping 1: pingobsa AND pingobsc both fall in Area A (must count once - // for Area A, not twice), pingobsb falls in Area B -- touches both. - tx1 := seedPingTrigger(t, srv, "arealb0000001", "#test", "Alice", "2026-01-15T10:00:00Z") - seedPingObservation(t, srv, tx1, "pingobsa", 9.0, `[]`, `[]`, 1736935200) - seedPingObservation(t, srv, tx1, "pingobsc", 6.0, `["aa"]`, `["pkrelay1"]`, 1736935210) - seedPingObservation(t, srv, tx1, "pingobsb", 4.0, `["aa","bb"]`, `["pkrelay1","pkrelay2"]`, 1736935260) - - // Ping 2: only pingobsa (Area A) -- Area B must NOT get credit for this one. - tx2 := seedPingTrigger(t, srv, "arealb0000002", "#test", "Bob", "2026-01-15T11:00:00Z") - seedPingObservation(t, srv, tx2, "pingobsa", 9.0, `[]`, `[]`, 1736938800) - - snap := srv.computeAllPingScores() - if snap == nil { - t.Fatal("computeAllPingScores returned nil") - } - counts := map[string]int{} - for _, e := range snap.AreaLeaderboard { - counts[e.Name] = e.Count - } - if counts["Area A"] != 2 { - t.Errorf("Area A count = %d, want 2 (both pings touch it, dedup within ping 1's two same-area stations)", counts["Area A"]) - } - if counts["Area B"] != 1 { - t.Errorf("Area B count = %d, want 1 (only ping 1 touches it)", counts["Area B"]) - } -} - -// TestComputeAllPingScores_AreaLeaderboardNoAreasConfigured confirms -// AreaLeaderboard stays nil (not present) rather than an empty-but-present -// array when the deployment has no areas configured -- matches -// annotatePacketPathTouchedAreas's own early-return for the same case. -func TestComputeAllPingScores_AreaLeaderboardNoAreasConfigured(t *testing.T) { - srv, _ := setupPingScoresFixture(t) - srv.cfg.Areas = nil - - txID := seedPingTrigger(t, srv, "arealbnone0001", "#test", "Alice", "2026-01-15T10:00:00Z") - seedPingObservation(t, srv, txID, "pingobsa", 9.0, `[]`, `[]`, 1736935200) - seedPingObservation(t, srv, txID, "pingobsb", 6.0, `["aa"]`, `["pkrelay1"]`, 1736935210) - - snap := srv.computeAllPingScores() - if snap == nil { - t.Fatal("computeAllPingScores returned nil") - } - if snap.AreaLeaderboard != nil { - t.Errorf("AreaLeaderboard = %+v, want nil when no areas are configured", snap.AreaLeaderboard) - } -} - // TestHandlePingScores_EmptyState confirms the endpoint returns a // well-formed 200 with zero-valued/omitted fields rather than an error // when no ping has ever been recorded -- an ordinary state, not a failure. diff --git a/public/ping-scores.js b/public/ping-scores.js index 1533a60e..838d3586 100644 --- a/public/ping-scores.js +++ b/public/ping-scores.js @@ -143,13 +143,6 @@ leaderboardTableHtml('Top Senders (30 days)', phIcon('megaphone'), data.senderLeaderboard, 'Sender') + leaderboardTableHtml('Top Relays', phIcon('repeat'), data.relayLeaderboard) + leaderboardTableHtml('Top First-Hearers', phIcon('eye'), data.observerLeaderboard) + - // areaLeaderboard is omitted entirely (not an empty array) when the - // deployment has no areas configured (cmd/server/ping_scores.go) -- - // skip the whole card rather than show an always-empty "Area - // Activity" section on every deployment without areas set up. - (data.areaLeaderboard && data.areaLeaderboard.length - ? leaderboardTableHtml('Area Activity', phIcon('map-pin'), data.areaLeaderboard, 'Area') - : '') + '' + ''; diff --git a/test-ping-scores.js b/test-ping-scores.js index 17eb83bb..bfc2d826 100644 --- a/test-ping-scores.js +++ b/test-ping-scores.js @@ -130,7 +130,6 @@ function makeSandbox(apiImpl) { relayLeaderboard: [{ pubkey: 'pkrelay1', name: 'RelayOne', count: 7 }], observerLeaderboard: [{ pubkey: 'pkobs1', name: 'ObsOne', count: 3 }], senderLeaderboard: [{ name: 'PingMaster', count: 12 }], - areaLeaderboard: [{ name: 'Area A', count: 9 }], }; const { container, getPage } = makeSandbox(() => Promise.resolve(data)); await getPage().init(container); @@ -145,30 +144,9 @@ function makeSandbox(apiImpl) { assert.ok(container.innerHTML.includes('All-Time Records'), 'should show the All-Time Records section heading, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('12.3'), 'should show the thisWeek farthest record km distinct from the all-time one, got: ' + container.innerHTML); assert.ok(container.innerHTML.includes('WeeklyRepeater'), 'should show the thisWeek record\'s node name, got: ' + container.innerHTML); - assert.ok(container.innerHTML.includes('Area Activity'), 'should show the Area Activity leaderboard heading, got: ' + container.innerHTML); - assert.ok(container.innerHTML.includes('Area A'), 'should show the area leaderboard entry, got: ' + container.innerHTML); passed++; - console.log(' ✅ renders all 5 records and all four leaderboards (including Top Senders and Area Activity) from a populated response'); - } catch (e) { failed++; console.log(' ❌ renders all 5 records and all four leaderboards (including Top Senders and Area Activity) from a populated response: ' + e.message); } - })(); - - await (async () => { - try { - // areaLeaderboard is omitted entirely (not an empty array) when the - // deployment has no areas configured -- the whole "Area Activity" - // card must be skipped, not shown empty/broken, on deployments that - // never set up areas. - const data = { - totalPings: 1, - generatedAt: new Date().toISOString(), - relayLeaderboard: [{ pubkey: 'pkrelay1', name: 'RelayOne', count: 7 }], - }; - const { container, getPage } = makeSandbox(() => Promise.resolve(data)); - await getPage().init(container); - assert.ok(!container.innerHTML.includes('Area Activity'), 'should NOT show an Area Activity card when areaLeaderboard is absent, got: ' + container.innerHTML); - passed++; - console.log(' ✅ Area Activity leaderboard card is skipped entirely when no areas are configured'); - } catch (e) { failed++; console.log(' ❌ Area Activity leaderboard card is skipped entirely when no areas are configured: ' + e.message); } + console.log(' ✅ renders all 5 records and all three leaderboards (including Top Senders) from a populated response'); + } catch (e) { failed++; console.log(' ❌ renders all 5 records and all three leaderboards (including Top Senders) from a populated response: ' + e.message); } })(); await (async () => { From ad1680ca0ab1a2931fa4c06c6934bd2570bee11f Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 28 Jul 2026 14:35:15 +0200 Subject: [PATCH 3/3] fix(#1868): decode CONTROL DISCOVER_REQ/RESP fields for humans Follow-up to #1806, which decoded the raw fields but left them unfriendly to read: - Detail sidepanel no longer falls through to a generic "Raw" row for CONTROL packets -- proper Subtype/Filter/Tag/Since/SNR/Pubkey rows with byte offsets, matching the ADVERT/TRACE field-table pattern. - DISCOVER_REQ's filter byte is a per-type bitmask (firmware checks `filter & (1 << ADV_TYPE_x)`); now rendered as type names (Companion/Repeater/Room Server/Sensor) instead of raw hex. - DISCOVER_RESP's node_type nibble gets the same human-readable label (shares the ADV_TYPE_* enum already used for ADVERT roles). - SNR converted from the wire int8 (value*4) to real dB, matching the /4.0 conversion TRACE's snrValues already apply in both decoders. - Responder pubkey (full 32B or 8B prefix) resolved to a clickable node link in the detail panel via /api/nodes/{pubkey}, which already handles prefix resolution server-side (#772); falls back to the first 8 hex chars when unresolved. The packet-row preview (rendered per-row, no live lookup) always shows the 8-hex-char form. Frontend-only (public/packets.js) -- the ingestor's decodeControl() already emits every field used here (#1806); this just makes the existing data readable. --- public/packets.js | 91 +++++++++++++++++++++++++++++++++++++++++++---- test-packets.js | 73 ++++++++++++++++++++++++++++++++++++- 2 files changed, 157 insertions(+), 7 deletions(-) diff --git a/public/packets.js b/public/packets.js index 90feb101..a8477a97 100644 --- a/public/packets.js +++ b/public/packets.js @@ -2865,6 +2865,33 @@ if (scrollContainer) scrollContainer.scrollTop = savedScrollTop; } + // #1868 — CONTROL DISCOVER_REQ/RESP node-type + SNR display helpers. + // node_type (RESP low nibble, single value) and filter (REQ byte, bitmask + // of these SAME type values -- firmware checks `filter & (1 << ADV_TYPE_x)`) + // share the ADV_TYPE_* enum already used for ADVERT role labels + // (cmd/ingestor/decoder.go's advertRole(), firmware/src/helpers/ + // AdvertDataHelpers.h:7-12): 0 NONE, 1 CHAT, 2 REPEATER, 3 ROOM, 4 SENSOR. + var CTRL_TYPE_LABELS = { 0: 'None', 1: 'Companion', 2: 'Repeater', 3: 'Room Server', 4: 'Sensor' }; + function ctrlTypeLabel(n) { + return CTRL_TYPE_LABELS[n] != null ? CTRL_TYPE_LABELS[n] : ('Unknown(' + n + ')'); + } + function ctrlFilterLabels(filter) { + var labels = []; + for (var bit = 0; bit <= 4; bit++) { + if (filter & (1 << bit)) labels.push(ctrlTypeLabel(bit)); + } + return labels; + } + // Firmware sends SNR as a wire-encoded int8 (value * 4); divide by 4.0 for + // real dB, same conversion already applied to TRACE's snrValues in both + // decoders (cmd/ingestor/decoder.go:1016, cmd/server/decoder.go:619) -- + // CONTROL's SNR just hasn't had the same conversion applied yet, and doing + // it here (display-only) avoids any ambiguity with already-stored raw + // values from packets ingested before this fix. + function ctrlSnrDb(raw) { + return (Number(raw) / 4.0).toFixed(2); + } + function getDetailPreview(decoded) { if (!decoded) return ''; // Channel messages (GRP_TXT) — show channel name and message text @@ -2941,7 +2968,8 @@ const parts = []; if (subtype === 'DISCOVER_REQ') { if (decoded.ctrlFilter != null) { - parts.push(`filter=0x${Number(decoded.ctrlFilter).toString(16).padStart(2, '0')}`); + const labels = ctrlFilterLabels(Number(decoded.ctrlFilter)); + parts.push(`filter=${labels.length ? labels.join('+') : '0x' + Number(decoded.ctrlFilter).toString(16).padStart(2, '0')}`); } if (decoded.ctrlTag != null) { parts.push(`tag=0x${(Number(decoded.ctrlTag) >>> 0).toString(16).padStart(8, '0')}`); @@ -2951,16 +2979,21 @@ } } else if (subtype === 'DISCOVER_RESP') { if (decoded.ctrlNodeType != null) { - parts.push(`type=${Number(decoded.ctrlNodeType)}`); + parts.push(`type=${ctrlTypeLabel(Number(decoded.ctrlNodeType))}`); } if (decoded.ctrlSNR != null) { - parts.push(`snr=${Number(decoded.ctrlSNR)}`); + parts.push(`snr=${ctrlSnrDb(decoded.ctrlSNR)}dB`); } if (decoded.ctrlTag != null) { parts.push(`tag=0x${(Number(decoded.ctrlTag) >>> 0).toString(16).padStart(8, '0')}`); } if (decoded.ctrlPubKey) { - parts.push(`pubkey=${escapeHtml(decoded.ctrlPubKey)}`); + // Row preview is synchronous/rendered per-row -- no live node + // lookup here (that would mean one API call per visible CONTROL + // row). Truncated to 8 hex chars, matching the same fallback + // used elsewhere in this file (e.g. srcLabel's pubKey.slice(0,8) + // in renderDetail) for an unresolved node identifier. + parts.push(`pubkey=${escapeHtml(decoded.ctrlPubKey.slice(0, 8))}…`); } } else if (decoded.ctrlFlags) { parts.push(`flags=0x${escapeHtml(decoded.ctrlFlags)}`); @@ -3134,6 +3167,17 @@ } catch {} } + // #1868 — CONTROL DISCOVER_RESP carries a responder pubkey (full 32B or + // 8B prefix) with no name attached. /api/nodes/{pubkey} already handles + // prefix resolution server-side (issue #772's short-URL fallback), so + // this works for both lengths the same way. Falls back to null (plain + // truncated hex in buildFieldTable) when unresolved/unknown/blacklisted. + let ctrlPubKeyNode = null; + if (decoded.type === 'CONTROL' && decoded.ctrlPubKey) { + const nd = await api(`/nodes/${decoded.ctrlPubKey}`, { ttl: 30000 }).catch(() => null); + if (nd?.node?.public_key) ctrlPubKeyNode = nd.node; + } + // Resolve hops: prefer server-side resolved_path, fall back to client-side HopResolver if (pathHops.length) { try { @@ -3327,7 +3371,7 @@ ${hasRawHex ? `
${buildHexLegend(ranges)}
${createColoredHexDump(effectivePkt.raw_hex || pkt.raw_hex, ranges)}
` : ''} - ${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)} + ${hasRawHex ? buildFieldTable(effectivePkt.raw_hex ? effectivePkt : pkt, decoded, pathHops, ranges, ctrlPubKeyNode) : buildDecodedTable(decoded)} ` : ''} ${observations.length > 1 ? ` @@ -3508,7 +3552,7 @@ return rows ? `${rows}
` : ''; } - function buildFieldTable(pkt, decoded, pathHops, ranges) { + function buildFieldTable(pkt, decoded, pathHops, ranges, ctrlPubKeyNode) { const buf = pkt.raw_hex || ''; const size = Math.floor(buf.length / 2); let rows = ''; @@ -3607,6 +3651,41 @@ rows += fieldRow(off + 1, 'Src Hash (1B)', decoded.srcHash || '', ''); rows += fieldRow(off + 2, 'MAC (2B)', decoded.mac || '', ''); rows += fieldRow(off + 4, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), ''); + } else if (decoded.type === 'CONTROL') { + // #1868 — CONTROL DISCOVER_REQ/RESP field breakdown, matching decoder + // layout in cmd/ingestor/decoder.go decodeControl(). Body fields are + // length-gated there too, so each row is only added when present. + const subtype = decoded.ctrlSubtype || 'CONTROL'; + rows += fieldRow(off, 'Subtype', escapeHtml(subtype), decoded.ctrlFlags ? 'byte0 high nibble, flags=0x' + escapeHtml(decoded.ctrlFlags) : ''); + if (subtype === 'DISCOVER_REQ') { + if (decoded.ctrlFilter != null) { + const labels = ctrlFilterLabels(Number(decoded.ctrlFilter)); + rows += fieldRow(off + 1, 'Filter (1B)', '0x' + Number(decoded.ctrlFilter).toString(16).padStart(2, '0'), labels.length ? 'Requesting: ' + labels.join(', ') : 'No types requested'); + } + if (decoded.ctrlTag != null) { + rows += fieldRow(off + 2, 'Tag (4B)', '0x' + (Number(decoded.ctrlTag) >>> 0).toString(16).toUpperCase().padStart(8, '0'), ''); + } + if (decoded.ctrlSince != null) { + rows += fieldRow(off + 6, 'Since (4B)', String(Number(decoded.ctrlSince) >>> 0), 'Unix epoch'); + } + } else if (subtype === 'DISCOVER_RESP') { + if (decoded.ctrlNodeType != null) { + rows += fieldRow(off, 'Node Type', escapeHtml(ctrlTypeLabel(Number(decoded.ctrlNodeType))), 'byte0 low nibble'); + } + if (decoded.ctrlSNR != null) { + rows += fieldRow(off + 1, 'SNR (1B)', ctrlSnrDb(decoded.ctrlSNR) + ' dB', 'wire value ' + decoded.ctrlSNR + ' ÷ 4.0'); + } + if (decoded.ctrlTag != null) { + rows += fieldRow(off + 2, 'Tag (4B)', '0x' + (Number(decoded.ctrlTag) >>> 0).toString(16).toUpperCase().padStart(8, '0'), ''); + } + if (decoded.ctrlPubKey) { + const pkLen = decoded.ctrlPubKey.length === 64 ? '32B' : '8B prefix'; + const pkValue = ctrlPubKeyNode + ? `${escapeHtml(ctrlPubKeyNode.name || ctrlPubKeyNode.public_key.slice(0, 8) + '…')}` + : escapeHtml(truncate(decoded.ctrlPubKey, 24)); + rows += fieldRow(off + 6, 'Pubkey (' + pkLen + ')', pkValue, ctrlPubKeyNode ? '' : 'Unknown node'); + } + } } else { rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), ''); } diff --git a/test-packets.js b/test-packets.js index 72d782c9..c37ac14a 100644 --- a/test-packets.js +++ b/test-packets.js @@ -517,6 +517,19 @@ console.log('\n=== packets.js: getDetailPreview ==='); assert(result.includes('tag'), 'should render tag field'); }); + // #1868 — filter is a bitmask (ADV_TYPE_* bit-per-type, per firmware's + // `filter & (1 << ADV_TYPE_x)`); bit 2 = ADV_TYPE_REPEATER, so filter=4 + // (1<<2) must render as the human-readable type name, not raw hex. + test('getDetailPreview renders CONTROL DISCOVER_REQ filter as type name(s), not raw hex', () => { + const result = api.getDetailPreview({ + type: 'CONTROL', + ctrlSubtype: 'DISCOVER_REQ', + ctrlFilter: 4, // 1 << 2 = ADV_TYPE_REPEATER + }); + assert(result.includes('Repeater'), 'should show "Repeater" for filter bit 2, got: ' + result); + assert(!/filter=0x/.test(result), 'should not fall back to raw hex when bits are known, got: ' + result); + }); + test('getDetailPreview handles CONTROL DISCOVER_RESP', () => { const result = api.getDetailPreview({ type: 'CONTROL', @@ -528,7 +541,34 @@ console.log('\n=== packets.js: getDetailPreview ==='); }); assert(result.includes('DISCOVER_RESP'), 'should label subtype'); assert(result.includes('snr') || result.includes('SNR'), 'should render snr'); - assert(result.includes('0001020304050607'), 'should render pubkey hex'); + // #1868: pubkey truncated to first 8 hex chars for the per-row preview + // (no live node lookup per row -- see the async detail-panel resolution + // instead), and full raw hex must NOT leak into the row. + assert(result.includes('00010203'), 'should render truncated pubkey prefix, got: ' + result); + assert(!result.includes('0001020304050607'), 'should NOT render the full raw pubkey in the row preview, got: ' + result); + }); + + // #1868 — node_type (ADV_TYPE_REPEATER=2) must render as "Repeater", not + // the raw number. + test('getDetailPreview renders CONTROL DISCOVER_RESP node type as a name, not a raw number', () => { + const result = api.getDetailPreview({ + type: 'CONTROL', + ctrlSubtype: 'DISCOVER_RESP', + ctrlNodeType: 2, + }); + assert(result.includes('Repeater'), 'should show "Repeater" for node type 2, got: ' + result); + assert(!/type=2\b/.test(result), 'should not show the raw type number, got: ' + result); + }); + + // #1868 — SNR is wire-encoded (value * 4); a raw 16 must display as 4.00 dB. + test('getDetailPreview converts CONTROL DISCOVER_RESP SNR from wire units to dB', () => { + const result = api.getDetailPreview({ + type: 'CONTROL', + ctrlSubtype: 'DISCOVER_RESP', + ctrlSNR: 16, + }); + assert(result.includes('4.00dB') || result.includes('4.00 dB'), 'should show 16/4.0=4.00 dB, got: ' + result); + assert(!/snr=16(?!\.)/.test(result), 'should not show the raw wire SNR value, got: ' + result); }); test('getDetailPreview handles CONTROL UNKNOWN subtype', () => { @@ -830,6 +870,37 @@ console.log('\n=== packets.js: buildFieldTable ==='); assert(result.includes('Raw')); }); + // #1868 — CONTROL no longer falls through to the generic "Raw" row; it + // gets a proper field breakdown matching decodeControl()'s byte layout. + test('buildFieldTable renders CONTROL DISCOVER_REQ with human-readable filter', () => { + const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 11 }; + const decoded = { type: 'CONTROL', ctrlSubtype: 'DISCOVER_REQ', ctrlFilter: 4, ctrlTag: 0xDEADBEEF, ctrlSince: 0x11223344 }; + const result = api.buildFieldTable(pkt, decoded, [], []); + assert(!result.includes('>Raw<'), 'should not fall through to the generic Raw row, got: ' + result); + assert(result.includes('DISCOVER_REQ')); + assert(result.includes('Repeater'), 'filter=4 (1<<2) should show "Repeater", got: ' + result); + assert(result.includes('DEADBEEF')); + }); + + test('buildFieldTable renders CONTROL DISCOVER_RESP with converted SNR and truncated pubkey when node is unresolved', () => { + const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 11 }; + const decoded = { type: 'CONTROL', ctrlSubtype: 'DISCOVER_RESP', ctrlNodeType: 2, ctrlSNR: 16, ctrlPubKey: '00'.repeat(32) }; + // 5th arg (ctrlPubKeyNode) omitted -- unresolved case. + const result = api.buildFieldTable(pkt, decoded, [], []); + assert(result.includes('Repeater'), 'node type 2 should show "Repeater", got: ' + result); + assert(result.includes('4.00 dB'), 'SNR 16/4.0 should show 4.00 dB, got: ' + result); + assert(!result.includes('#/nodes/'), 'should not render a node link when unresolved, got: ' + result); + }); + + test('buildFieldTable renders CONTROL DISCOVER_RESP pubkey as a clickable node link when resolved', () => { + const pkt = { raw_hex: 'c040', route_type: 1, payload_type: 11 }; + const decoded = { type: 'CONTROL', ctrlSubtype: 'DISCOVER_RESP', ctrlPubKey: 'ab'.repeat(32) }; + const ctrlPubKeyNode = { public_key: 'ab'.repeat(32), name: 'KnownRepeater' }; + const result = api.buildFieldTable(pkt, decoded, [], [], ctrlPubKeyNode); + assert(result.includes('#/nodes/' + ctrlPubKeyNode.public_key), 'should link to the resolved node, got: ' + result); + assert(result.includes('KnownRepeater'), 'should show the resolved node name, got: ' + result); + }); + test('buildFieldTable hash_size calculation', () => { // Path byte 0xC0 → bits 7-6 = 3 → hash_size = 4, but hash_count = 0 // Since #653: when hashCount == 0, shows "hash_count=0 (direct advert)" instead of hash_size