mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 01:23:54 +00:00
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.
This commit is contained in:
@@ -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{}{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
: '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
|
||||
+24
-2
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user