mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 16:07:55 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user