From c74a005247e058d35dd52280b2799c30ab64caa8 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 18 Jul 2026 12:04:08 +0200 Subject: [PATCH] feat: channel-messages-only scoped/unscoped breakdown on the Scopes tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds channelMessages to /api/scope-stats: the same scoped/unscoped/ unknown question as the main Summary, but restricted to payload_type=5 (channel chat) instead of all observed traffic. Most channel chat is plain FLOOD rather than transport-scoped, so this can read very differently from the all-traffic numbers — answers "how many of our actual channel messages carry a region scope" directly instead of requiring the reader to infer it from the broader stats. New GetChannelMessageScopeStats() mirrors GetScopeStats' query shape but scopes TotalMessages to ALL route types for payload_type=5 (not just route_type 0/3), since restricting to transport routes would answer a different question than "how many channel messages, period". Frontend renders a small "Channel Messages" stat-card row under the main summary cards, window-scoped like the rest of the tab. --- cmd/server/db.go | 51 ++++++++++++++++++++++++++++++++ cmd/server/routes.go | 4 +++ cmd/server/routes_test.go | 61 +++++++++++++++++++++++++++++++++++++++ cmd/server/types.go | 17 +++++++++++ public/analytics.js | 31 ++++++++++++++++++++ 5 files changed, 164 insertions(+) diff --git a/cmd/server/db.go b/cmd/server/db.go index d8ffa77d..debea55f 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -2961,6 +2961,57 @@ func (db *DB) GetScopeStats(window string) (*ScopeStatsResponse, error) { return resp, nil } +// GetChannelMessageScopeStats narrows the scoped/unscoped/unknown question +// to channel chat specifically (payload_type=5), for the given window. +// Unlike GetScopeStats' TransportTotal (route_type 0/3 only), TotalMessages +// here covers ALL route types — most channel chat is plain FLOOD, so +// restricting to transport routes would answer a different question than +// "how many of our channel messages are scoped". +func (db *DB) GetChannelMessageScopeStats(window string) (*ChannelScopeStats, error) { + if !db.hasScopeName { + return nil, fmt.Errorf("scope_name column not present — run ingestor to apply migrations") + } + + var since string + switch window { + case "1h": + since = time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + case "7d": + since = time.Now().Add(-7 * 24 * time.Hour).UTC().Format(time.RFC3339) + default: + since = time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339) + } + + stats := &ChannelScopeStats{} + row := db.conn.QueryRow(` + SELECT + COUNT(*) AS transport_total, + COUNT(scope_name) AS scoped, + COALESCE(SUM(CASE WHEN scope_name IS NULL THEN 1 ELSE 0 END), 0) AS unscoped, + COALESCE(SUM(CASE WHEN scope_name = '' THEN 1 ELSE 0 END), 0) AS unknown_scope + FROM transmissions + WHERE payload_type = 5 AND `+routeTypeTransportSQL+` AND first_seen >= ? + `, since) + var transportTotal int + if err := row.Scan(&transportTotal, &stats.Scoped, &stats.Unscoped, &stats.UnknownScope); err != nil { + return nil, fmt.Errorf("channel scope summary query: %w", err) + } + + // Non-transport channel messages (plain FLOOD/DIRECT) never carry a + // scope per MeshCore protocol — fold into Unscoped, mirroring #1838. + var nonTransportCount int + if err := db.conn.QueryRow(` + SELECT COUNT(*) FROM transmissions + WHERE payload_type = 5 AND `+routeTypeNonTransportSQL+` AND first_seen >= ? + `, since).Scan(&nonTransportCount); err != nil { + return nil, fmt.Errorf("channel scope non-transport count query: %w", err) + } + stats.Unscoped += nonTransportCount + stats.TotalMessages = transportTotal + nonTransportCount + + return stats, nil +} + // 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.go b/cmd/server/routes.go index 8dda5f81..a8fb3b98 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -3581,6 +3581,10 @@ func (s *Server) handleScopeStats(w http.ResponseWriter, r *http.Request) { resp.OriginatingNodesByRegion = originating } + if chanStats, err := s.db.GetChannelMessageScopeStats(window); err == nil { + resp.ChannelMessages = chanStats + } + s.scopeStatsMu.Lock() if s.scopeStatsCache == nil { s.scopeStatsCache = make(map[string]*ScopeStatsResponse) diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 0255a044..925c0c8a 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -4244,6 +4244,67 @@ func TestHandleScopeStats(t *testing.T) { if resp.TimeSeries == nil { t.Error("timeSeries is nil") } + // All 6 seed rows above are payload_type=5 (channel messages) — same + // fixture, so ChannelMessages should mirror Summary exactly here. + if resp.ChannelMessages == nil { + t.Fatal("channelMessages is nil") + } + if resp.ChannelMessages.TotalMessages != 6 { + t.Errorf("channelMessages.totalMessages = %d, want 6", resp.ChannelMessages.TotalMessages) + } + if resp.ChannelMessages.Scoped != 3 { + t.Errorf("channelMessages.scoped = %d, want 3", resp.ChannelMessages.Scoped) + } + if resp.ChannelMessages.Unscoped != 3 { + t.Errorf("channelMessages.unscoped = %d, want 3", resp.ChannelMessages.Unscoped) + } + if resp.ChannelMessages.UnknownScope != 1 { + t.Errorf("channelMessages.unknownScope = %d, want 1", resp.ChannelMessages.UnknownScope) + } +} + +// TestHandleScopeStats_ChannelMessagesExcludesOtherPayloadTypes verifies the +// payload_type=5 filter: a non-channel transmission (e.g. an ADVERT) with a +// scope must not be counted in ChannelMessages even though it affects the +// all-traffic Summary. +func TestHandleScopeStats_ChannelMessagesExcludesOtherPayloadTypes(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) + if _, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,scope_name) VALUES (?,?,?,?,?,?)`, + "aa", "chan1", now, 0, 5, "#belgium", + ); err != nil { + t.Fatalf("seed channel row: %v", err) + } + if _, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,scope_name) VALUES (?,?,?,?,?,?)`, + "bb", "advert1", now, 0, 4, "#belgium", + ); err != nil { + t.Fatalf("seed advert row: %v", err) + } + + req := httptest.NewRequest("GET", "/api/scope-stats?window=24h", nil) + w := httptest.NewRecorder() + srv.handleScopeStats(w, req) + + var resp ScopeStatsResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Summary.Scoped != 2 { + t.Errorf("Summary.Scoped = %d, want 2 (both rows)", resp.Summary.Scoped) + } + if resp.ChannelMessages == nil || resp.ChannelMessages.TotalMessages != 1 || resp.ChannelMessages.Scoped != 1 { + t.Errorf("channelMessages = %+v, want totalMessages=1 scoped=1 (advert excluded)", resp.ChannelMessages) + } } // TestHandleScopeStats_UnusedRegions verifies the region-utilization diff: diff --git a/cmd/server/types.go b/cmd/server/types.go index 648299cf..618d44b2 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -102,6 +102,19 @@ type ScopeStatsSummary struct { UnknownScope int `json:"unknownScope"` } +// ChannelScopeStats answers a narrower question than ScopeStatsSummary: +// of channel chat messages specifically (payload_type=5 / GRP_TXT), how +// many actually carry a resolvable region scope vs none vs unresolved. +// TotalMessages covers ALL route types (unlike ScopeStatsSummary's +// TransportTotal, which only counts route_type 0/3) since most channel +// chat is plain FLOOD, not transport-scoped. +type ChannelScopeStats struct { + TotalMessages int `json:"totalMessages"` + Scoped int `json:"scoped"` + Unscoped int `json:"unscoped"` + UnknownScope int `json:"unknownScope"` +} + type ScopeRegionCount struct { Name string `json:"name"` Count int `json:"count"` @@ -137,6 +150,10 @@ type ScopeStatsResponse struct { // running that region themselves, not just relaying someone else's // scoped traffic. All-time, like RepeatersByRegion. OriginatingNodesByRegion []ScopeRegionRepeaters `json:"originatingNodesByRegion,omitempty"` + // ChannelMessages narrows the same scoped/unscoped/unknown question to + // channel chat specifically (payload_type=5), window-scoped like + // Summary above — see ChannelScopeStats doc. + ChannelMessages *ChannelScopeStats `json:"channelMessages,omitempty"` } type RepeaterRef struct { diff --git a/public/analytics.js b/public/analytics.js index 71b0d0d2..42b282ef 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4484,6 +4484,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = }).join('') + '' + '
' + + '
' + '
Loading scope stats…
' + '' + '' + @@ -4554,6 +4555,36 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData = }).join(''); } + // Channel-messages-only breakdown: same scoped/unscoped/unknown + // question as the cards above, but restricted to payload_type=5 + // (channel chat) — most channel traffic is plain FLOOD, so this can + // read very differently from the all-traffic numbers above. + var chanEl = document.getElementById('scopes-channel-messages'); + if (chanEl) { + var cm = d.channelMessages; + if (cm && cm.totalMessages > 0) { + var cmOverall = cm.scoped + cm.unscoped; + chanEl.innerHTML = + '

Channel Messages

' + + '

Same scoped/unscoped/unknown breakdown, restricted to channel chat messages only.

' + + '
' + + [ + { label: 'Total Messages', value: cm.totalMessages.toLocaleString(), note: null }, + { label: 'Scoped', value: cm.scoped.toLocaleString(), note: pct(cm.scoped, cmOverall) + ' of channel messages' }, + { label: 'Unscoped', value: cm.unscoped.toLocaleString(), note: pct(cm.unscoped, cmOverall) + ' of channel messages' }, + { label: 'Unknown Scope', value: cm.unknownScope.toLocaleString(), note: pct(cm.unknownScope, cm.scoped) + ' of scoped' }, + ].map(function(c) { + return '
' + c.value + '
' + + '
' + c.label + '
' + + (c.note ? '
' + c.note + '
' : '') + + '
'; + }).join('') + + '
'; + } else { + chanEl.innerHTML = ''; + } + } + // Per-region table var tbodyEl = document.getElementById('scopes-tbody'); if (tbodyEl) {
RegionMessages% of Scoped