From 524b6326fdb83f6e1729bdbcf465ca7903ac7228 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 20 Jul 2026 11:21:46 +0200 Subject: [PATCH] feat: show which regions are actually used per channel in Scope Adoption by Channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped/Unscoped/Unknown counts answered "how much" but not "which regions" — a channel spanning multiple hashRegions areas looked identical to one using just one. New "Regions Used" column lists the distinct scope_name values seen on each channel's scoped messages, most-used first. Backend: GetChannelScopeAdoption now also queries per-(channel, scope_name) counts and attaches them as ChannelScopeAdoption.Regions. Non-fatal on query failure — adoption counts still render without it. --- cmd/server/db.go | 43 ++++++++++++++++++++++++++ cmd/server/routes_test.go | 64 +++++++++++++++++++++++++++++++++++++++ cmd/server/types.go | 6 ++++ public/analytics.js | 6 +++- 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index a774a12a..06c9827d 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -3128,9 +3128,52 @@ func (db *DB) GetChannelScopeAdoption(window string) ([]ChannelScopeAdoption, er if err := rows.Err(); err != nil { return nil, fmt.Errorf("channel scope adoption iteration: %w", err) } + + regionsByChannel, err := db.getChannelScopeRegions(since) + if err != nil { + // Non-fatal: the adoption counts above are still useful without + // the per-channel region breakdown. + log.Printf("WARN getChannelScopeRegions: %v", err) + } else { + for i := range result { + result[i].Regions = regionsByChannel[result[i].Channel] + } + } + return result, nil } +// getChannelScopeRegions answers "which regions" for GetChannelScopeAdoption's +// "how many scoped messages" — for each channel, which distinct scope_name +// values have actually been seen on its scoped messages, most-used first. +func (db *DB) getChannelScopeRegions(since string) (map[string][]string, error) { + rows, err := db.conn.Query(` + SELECT + COALESCE(NULLIF(channel_hash, ''), '(unknown channel)') AS channel, + scope_name, + COUNT(*) AS cnt + FROM transmissions + WHERE payload_type = 5 AND first_seen >= ? AND `+routeTypeTransportSQL+` AND scope_name IS NOT NULL AND scope_name != '' + GROUP BY channel, scope_name + ORDER BY channel, cnt DESC + `, since) + if err != nil { + return nil, fmt.Errorf("channel scope regions query: %w", err) + } + defer rows.Close() + + result := make(map[string][]string) + for rows.Next() { + var channel, region string + var cnt int + if err := rows.Scan(&channel, ®ion, &cnt); err != nil { + continue + } + result[channel] = append(result[channel], region) + } + return result, rows.Err() +} + // GetMatchedRegionNames returns the set of scope_name values that have ever // matched at least one transmission still in retention (NULL and empty-string // "unknown" rows are excluded). Used to diff against the operator's diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 9d5cc90f..e6619976 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -4398,10 +4398,74 @@ func TestHandleScopeStats_ChannelScopeAdoption(t *testing.T) { if test.Channel != "#test" || test.TotalMessages != 3 || test.Scoped != 2 || test.Unscoped != 1 { t.Errorf("channelScopeAdoption[0] = %+v, want {#test total=3 scoped=2 unscoped=1}", test) } + if len(test.Regions) != 1 || test.Regions[0] != "#belgium" { + t.Errorf("channelScopeAdoption[0].Regions = %+v, want [#belgium]", test.Regions) + } wardriving := resp.ChannelScopeAdoption[1] if wardriving.Channel != "#wardriving" || wardriving.TotalMessages != 1 || wardriving.Scoped != 0 || wardriving.Unscoped != 1 { t.Errorf("channelScopeAdoption[1] = %+v, want {#wardriving total=1 scoped=0 unscoped=1}", wardriving) } + if len(wardriving.Regions) != 0 { + t.Errorf("channelScopeAdoption[1].Regions = %+v, want none — #wardriving has no scoped messages", wardriving.Regions) + } +} + +// TestHandleScopeStats_ChannelScopeAdoption_RegionsOrderedByUsage verifies +// the Regions field on each channel: which distinct scope_name values its +// scoped messages actually carry, most-used first — a channel with mixed +// region traffic (a shared/public channel spanning several hashRegions +// areas) must list every region it's seen, not just the top one. +func TestHandleScopeStats_ChannelScopeAdoption_RegionsOrderedByUsage(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(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + rows := []struct { + hash, channel, scope string + }{ + // #public: 3x #dk, 2x #dk-oj, 1x #se — #dk must sort first. + {"h1", "#public", "#dk"}, {"h2", "#public", "#dk"}, {"h3", "#public", "#dk"}, + {"h4", "#public", "#dk-oj"}, {"h5", "#public", "#dk-oj"}, + {"h6", "#public", "#se"}, + } + for _, r := range rows { + if _, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,scope_name) VALUES (?,?,?,0,5,?,?)`, + "aa", r.hash, now, r.channel, r.scope, + ); err != nil { + t.Fatalf("seed row %s: %v", r.hash, err) + } + } + + 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.ChannelScopeAdoption) != 1 { + t.Fatalf("channelScopeAdoption = %+v, want 1 entry", resp.ChannelScopeAdoption) + } + got := resp.ChannelScopeAdoption[0].Regions + want := []string{"#dk", "#dk-oj", "#se"} + if len(got) != len(want) { + t.Fatalf("Regions = %+v, want %+v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Regions[%d] = %q, want %q (most-used-first order) — full: %+v", i, got[i], want[i], got) + } + } } // TestHandleScopeStats_ChannelScopeAdoption_Uncapped verifies the endpoint diff --git a/cmd/server/types.go b/cmd/server/types.go index e2083444..f04e9a49 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -124,6 +124,12 @@ type ChannelScopeAdoption struct { Scoped int `json:"scoped"` Unscoped int `json:"unscoped"` UnknownScope int `json:"unknownScope"` + // Regions is which specific scope_name values have actually been seen + // on this channel's scoped messages, most-used first. Distinct from + // Scoped (a count) — this answers "which regions", not just "how + // many scoped messages". Omitted when the channel has no scoped + // messages in the window. + Regions []string `json:"regions,omitempty"` } type ScopeRegionCount struct { diff --git a/public/analytics.js b/public/analytics.js index 860310a6..8bce779f 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4744,16 +4744,20 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf var adoptRows = filtered.map(function(ca) { var caOverall = ca.scoped + ca.unscoped; var label = isEncrypted(ca) ? 'Encrypted (0x' + ca.channel.slice(4).toUpperCase() + ')' : ca.channel; + var regionsHtml = (ca.regions && ca.regions.length) + ? ca.regions.map(function(r) { return '' + esc(r) + ''; }).join(', ') + : '—'; return '' + '' + esc(label) + '' + '' + ca.totalMessages.toLocaleString() + '' + '' + ca.scoped.toLocaleString() + ' (' + pct(ca.scoped, caOverall) + ')' + '' + ca.unscoped.toLocaleString() + '' + '' + ca.unknownScope.toLocaleString() + '' + + '' + regionsHtml + '' + ''; }).join(''); adoptBody = '' + - '' + + '' + '' + adoptRows + '' + '
ChannelMessagesScopedUnscopedUnknown
ChannelMessagesScopedUnscopedUnknownRegions Used
'; } else if (adoption.length > 0) {