mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 17:07:53 +00:00
feat: repeaters-by-region breakdown on the Scopes analytics tab
Adds repeatersByRegion to /api/scope-stats: for every region that has ever matched a transmission, which distinct repeaters/rooms have relayed traffic carrying that scope. Sourced from the same 5-min background-recomputed bulk relay-info cache the Nodes page already uses (GetRepeaterRelayInfoMap / TransportedScopes, #1751) — no new expensive computation, just an inversion + name lookup. Frontend renders a collapsible per-region repeater list (name links to the node detail page) under a new "Repeaters by Region" section, explicitly framed as a coverage/redundancy signal: a region carried by only one repeater is a single point of failure for that area.
This commit is contained in:
@@ -2155,6 +2155,39 @@ func (db *DB) GetNodeLocationsByKeys(keys []string) map[string]map[string]interf
|
||||
return result
|
||||
}
|
||||
|
||||
// GetNodeNamesByKeys batch-resolves pubkey -> display name for the given
|
||||
// keys. Missing/unnamed nodes are simply absent from the result map — the
|
||||
// caller falls back to a truncated pubkey. Used to label repeaters in the
|
||||
// scope-stats "repeaters by region" breakdown without pulling full node
|
||||
// rows for a set that's typically small (repeaters that have transported
|
||||
// at least one scoped packet).
|
||||
func (db *DB) GetNodeNamesByKeys(keys []string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
if len(keys) == 0 {
|
||||
return result
|
||||
}
|
||||
placeholders := make([]string, len(keys))
|
||||
args := make([]interface{}, len(keys))
|
||||
for i, k := range keys {
|
||||
placeholders[i] = "?"
|
||||
args[i] = strings.ToLower(k)
|
||||
}
|
||||
query := "SELECT public_key, name FROM nodes WHERE public_key IN (" + strings.Join(placeholders, ",") + ")"
|
||||
rows, err := db.conn.Query(query, args...)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pk string
|
||||
var name sql.NullString
|
||||
if rows.Scan(&pk, &name) == nil && name.Valid && name.String != "" {
|
||||
result[strings.ToLower(pk)] = name.String
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -3519,6 +3519,48 @@ func (s *Server) handleScopeStats(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if s.store != nil {
|
||||
// windowHours is irrelevant to TransportedScopes (explicitly
|
||||
// not time-windowed — see RepeaterRelayInfo doc comment) and the
|
||||
// map is served from the 5-min background-recomputed cache
|
||||
// regardless of the value passed once warm, so reusing whatever
|
||||
// handleNodes uses costs nothing extra here.
|
||||
relayWindow := s.cfg.GetHealthThresholds().RelayActiveHours
|
||||
relayMap := s.store.GetRepeaterRelayInfoMap(relayWindow)
|
||||
|
||||
byRegion := make(map[string][]string) // region -> pubkeys
|
||||
pubkeySet := make(map[string]bool)
|
||||
for pk, info := range relayMap {
|
||||
for _, region := range info.TransportedScopes {
|
||||
byRegion[region] = append(byRegion[region], pk)
|
||||
pubkeySet[pk] = true
|
||||
}
|
||||
}
|
||||
if len(pubkeySet) > 0 {
|
||||
pubkeys := make([]string, 0, len(pubkeySet))
|
||||
for pk := range pubkeySet {
|
||||
pubkeys = append(pubkeys, pk)
|
||||
}
|
||||
names := s.db.GetNodeNamesByKeys(pubkeys)
|
||||
|
||||
repeaters := make([]ScopeRegionRepeaters, 0, len(byRegion))
|
||||
for region, pks := range byRegion {
|
||||
refs := make([]RepeaterRef, 0, len(pks))
|
||||
for _, pk := range pks {
|
||||
name := names[pk]
|
||||
if name == "" {
|
||||
name = pk
|
||||
}
|
||||
refs = append(refs, RepeaterRef{Name: name, PublicKey: pk})
|
||||
}
|
||||
sort.Slice(refs, func(i, j int) bool { return refs[i].Name < refs[j].Name })
|
||||
repeaters = append(repeaters, ScopeRegionRepeaters{Region: region, Count: len(refs), Repeaters: refs})
|
||||
}
|
||||
sort.Slice(repeaters, func(i, j int) bool { return repeaters[i].Count > repeaters[j].Count })
|
||||
resp.RepeatersByRegion = repeaters
|
||||
}
|
||||
}
|
||||
|
||||
s.scopeStatsMu.Lock()
|
||||
if s.scopeStatsCache == nil {
|
||||
s.scopeStatsCache = make(map[string]*ScopeStatsResponse)
|
||||
|
||||
@@ -4297,6 +4297,58 @@ func TestHandleScopeStats_UnusedRegions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleScopeStats_RepeatersByRegion verifies the "which repeaters
|
||||
// transported this region" breakdown, sourced from the same bulk relay-info
|
||||
// cache the Nodes page uses (GetRepeaterRelayInfoMap / TransportedScopes,
|
||||
// #1751) and cross-referenced against nodes.name for display.
|
||||
func TestHandleScopeStats_RepeatersByRegion(t *testing.T) {
|
||||
srv, _ := setupTestServer(t)
|
||||
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
|
||||
t.Fatalf("add scope_name column: %v", err)
|
||||
}
|
||||
srv.db.hasScopeName = true
|
||||
|
||||
if _, err := srv.db.conn.Exec(
|
||||
`INSERT INTO nodes (public_key, name) VALUES ('aabbccdd0011', 'TestRepeater1')`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
pt5 := 5 // GRP_TXT — non-advert, so it counts toward TransportedScopes
|
||||
tx := &StoreTx{
|
||||
ID: 1,
|
||||
Hash: "txhash1",
|
||||
FirstSeen: time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano),
|
||||
PayloadType: &pt5,
|
||||
ScopeName: "#belgium",
|
||||
}
|
||||
srv.store = &PacketStore{
|
||||
byPathHop: map[string][]*StoreTx{"aabbccdd0011": {tx}},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/scope-stats?window=24h", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.handleScopeStats(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp ScopeStatsResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.RepeatersByRegion) != 1 {
|
||||
t.Fatalf("repeatersByRegion = %v, want 1 entry", resp.RepeatersByRegion)
|
||||
}
|
||||
rbr := resp.RepeatersByRegion[0]
|
||||
if rbr.Region != "#belgium" || rbr.Count != 1 {
|
||||
t.Errorf("repeatersByRegion[0] = %+v, want region=#belgium count=1", rbr)
|
||||
}
|
||||
if len(rbr.Repeaters) != 1 || rbr.Repeaters[0].Name != "TestRepeater1" || rbr.Repeaters[0].PublicKey != "aabbccdd0011" {
|
||||
t.Errorf("repeaters = %+v, want [{TestRepeater1 aabbccdd0011}]", rbr.Repeaters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleScopeStatsInvalidWindow(t *testing.T) {
|
||||
srv, _ := setupTestServer(t)
|
||||
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
|
||||
|
||||
@@ -125,6 +125,24 @@ type ScopeStatsResponse struct {
|
||||
// Omitted (both zero-value) when the server config has no hashRegions.
|
||||
ConfiguredRegions int `json:"configuredRegions,omitempty"`
|
||||
UnusedRegions []string `json:"unusedRegions,omitempty"`
|
||||
// RepeatersByRegion is all-time (not window-scoped), like
|
||||
// UnusedRegions: for each region that has ever matched a transmission,
|
||||
// which distinct repeaters/rooms have relayed traffic carrying that
|
||||
// scope (nodes.go transported_scopes, #1751), sourced from the same
|
||||
// 5-minute-cached bulk relay-info map the Nodes page uses. Omitted
|
||||
// when the in-memory store isn't available (DB-only mode).
|
||||
RepeatersByRegion []ScopeRegionRepeaters `json:"repeatersByRegion,omitempty"`
|
||||
}
|
||||
|
||||
type RepeaterRef struct {
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
}
|
||||
|
||||
type ScopeRegionRepeaters struct {
|
||||
Region string `json:"region"`
|
||||
Count int `json:"count"`
|
||||
Repeaters []RepeaterRef `json:"repeaters"`
|
||||
}
|
||||
|
||||
// ─── Health ────────────────────────────────────────────────────────────────────
|
||||
|
||||
+30
-1
@@ -4490,7 +4490,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
'<tbody id="scopes-tbody"></tbody>' +
|
||||
'</table>' +
|
||||
'<div id="scopes-chart"></div>' +
|
||||
'<div id="scopes-utilization" style="margin-top:16px"></div>';
|
||||
'<div id="scopes-utilization" style="margin-top:16px"></div>' +
|
||||
'<div id="scopes-repeaters" style="margin-top:16px"></div>';
|
||||
|
||||
// Attach window-button click listeners (once)
|
||||
el.querySelectorAll('[data-win]').forEach(function(btn) {
|
||||
@@ -4649,6 +4650,34 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
utilEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Repeaters by region: all-time (not window-scoped), which repeaters
|
||||
// have relayed traffic carrying each scope. Sourced from the same
|
||||
// 5-min-cached bulk relay-info map the Nodes page uses, so this is
|
||||
// cheap — no new per-request computation.
|
||||
var repEl = document.getElementById('scopes-repeaters');
|
||||
if (repEl) {
|
||||
var byRegion = d.repeatersByRegion || [];
|
||||
if (byRegion.length > 0) {
|
||||
var rows = byRegion.map(function(rbr) {
|
||||
var links = rbr.repeaters.map(function(rp) {
|
||||
return '<a href="#/nodes/' + encodeURIComponent(rp.publicKey) + '">' + esc(rp.name) + '</a>';
|
||||
}).join(', ');
|
||||
return '<details style="margin-bottom:6px">' +
|
||||
'<summary style="cursor:pointer"><code>' + esc(rbr.region) + '</code> — ' + rbr.count.toLocaleString() + ' repeater' + (rbr.count === 1 ? '' : 's') + '</summary>' +
|
||||
'<div class="text-muted" style="font-size:11px;margin-top:6px;margin-left:12px;max-height:200px;overflow-y:auto;line-height:1.8">' + links + '</div>' +
|
||||
'</details>';
|
||||
}).join('');
|
||||
repEl.innerHTML =
|
||||
'<h4 style="margin:0 0 4px">Repeaters by Region</h4>' +
|
||||
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">' +
|
||||
'All-time, not limited to the window above — which repeaters have relayed traffic carrying each region scope. A region carried by only 1 repeater is a single point of failure for that area.' +
|
||||
'</p>' +
|
||||
rows;
|
||||
} else {
|
||||
repEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load(selectedWindow);
|
||||
|
||||
Reference in New Issue
Block a user