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) + '| Channel | Messages | Scoped | Unscoped | Unknown | |
|---|---|---|---|---|---|
| Channel | Messages | Scoped | Unscoped | Unknown | Regions Used |