feat: channel-messages-only scoped/unscoped breakdown on the Scopes tab

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.
This commit is contained in:
dborup
2026-07-18 12:04:08 +02:00
parent 40dc297e2b
commit c74a005247
5 changed files with 164 additions and 0 deletions
+51
View File
@@ -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
+4
View File
@@ -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)
+61
View File
@@ -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:
+17
View File
@@ -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 {
+31
View File
@@ -4484,6 +4484,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
}).join('') +
'</div>' +
'<div id="scopes-cards" class="stats-grid" style="margin-bottom:16px"></div>' +
'<div id="scopes-channel-messages" style="margin-bottom:16px"></div>' +
'<div class="text-center text-muted" id="scopes-loading" style="padding:20px">Loading scope stats…</div>' +
'<table class="data-table analytics-table" style="margin-bottom:8px">' +
'<thead><tr><th>Region</th><th>Messages</th><th>% of Scoped</th></tr></thead>' +
@@ -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 =
'<h4 style="margin:0 0 4px">Channel Messages</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">Same scoped/unscoped/unknown breakdown, restricted to channel chat messages only.</p>' +
'<div class="stats-grid">' +
[
{ 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 '<div class="stat-card"><div class="stat-value">' + c.value + '</div>' +
'<div class="stat-label">' + c.label + '</div>' +
(c.note ? '<div class="stat-note text-muted" style="font-size:11px">' + c.note + '</div>' : '') +
'</div>';
}).join('') +
'</div>';
} else {
chanEl.innerHTML = '';
}
}
// Per-region table
var tbodyEl = document.getElementById('scopes-tbody');
if (tbodyEl) {