mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 17:07:53 +00:00
feat: hour-of-day activity heatmap on the Scopes analytics tab
Adds hourlyActivityByRegion to /api/scope-stats: each region's message counts bucketed by hour-of-day (0-23 UTC), aggregated across every day in the window — answers "when during a typical day is this region active" rather than "how did volume change over the window" (that's the existing chronological TimeSeries chart). Frontend renders a compact heatmap: one row per region, 24 hour columns, color intensity normalized per-row (each region's own busiest hour) so a quiet region's daily shape stays visible next to a loud one instead of being crushed toward zero.
This commit is contained in:
@@ -2958,6 +2958,52 @@ func (db *DB) GetScopeStats(window string) (*ScopeStatsResponse, error) {
|
||||
resp.TimeSeries = []ScopeTimePoint{}
|
||||
}
|
||||
|
||||
// Hour-of-day activity per region: same named-region set as ByRegion,
|
||||
// but bucketed by hour-of-day (0-23, UTC) instead of chronological
|
||||
// time — answers "when during a typical day is this region active"
|
||||
// rather than "how did volume change over the window". Aggregated
|
||||
// across every day in the window, so this reads best on 7d (a single
|
||||
// 1h/24h window won't show a meaningful daily shape).
|
||||
hourRows, err := db.conn.Query(`
|
||||
SELECT scope_name, CAST(strftime('%H', first_seen) AS INTEGER) AS hour, COUNT(*) AS cnt
|
||||
FROM transmissions
|
||||
WHERE ` + routeTypeTransportSQL + ` AND scope_name IS NOT NULL AND scope_name != '' AND first_seen >= ?
|
||||
GROUP BY scope_name, hour
|
||||
`, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scope hourly activity query: %w", err)
|
||||
}
|
||||
defer hourRows.Close()
|
||||
hourly := make(map[string]*ScopeHourlyActivity)
|
||||
var hourlyOrder []string
|
||||
for hourRows.Next() {
|
||||
var region string
|
||||
var hour, cnt int
|
||||
if hourRows.Scan(®ion, &hour, &cnt) != nil {
|
||||
continue
|
||||
}
|
||||
if hour < 0 || hour > 23 {
|
||||
continue
|
||||
}
|
||||
ha, ok := hourly[region]
|
||||
if !ok {
|
||||
ha = &ScopeHourlyActivity{Region: region}
|
||||
hourly[region] = ha
|
||||
hourlyOrder = append(hourlyOrder, region)
|
||||
}
|
||||
ha.Hours[hour] = cnt
|
||||
}
|
||||
if err := hourRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scope hourly activity iteration: %w", err)
|
||||
}
|
||||
resp.HourlyActivityByRegion = make([]ScopeHourlyActivity, 0, len(hourlyOrder))
|
||||
for _, region := range hourlyOrder {
|
||||
resp.HourlyActivityByRegion = append(resp.HourlyActivityByRegion, *hourly[region])
|
||||
}
|
||||
sort.Slice(resp.HourlyActivityByRegion, func(i, j int) bool {
|
||||
return resp.HourlyActivityByRegion[i].Region < resp.HourlyActivityByRegion[j].Region
|
||||
})
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4263,6 +4263,85 @@ func TestHandleScopeStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleScopeStats_HourlyActivityByRegion verifies the hour-of-day
|
||||
// bucketing: counts must land in the bucket matching first_seen's actual
|
||||
// hour-of-day (UTC), grouped separately per region.
|
||||
func TestHandleScopeStats_HourlyActivityByRegion(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)
|
||||
}
|
||||
|
||||
t1 := time.Now().UTC().Add(-2 * time.Hour)
|
||||
t2 := time.Now().UTC().Add(-1 * time.Hour)
|
||||
hour1, hour2 := t1.Hour(), t2.Hour()
|
||||
|
||||
rows := []struct {
|
||||
hash string
|
||||
scope string
|
||||
ts time.Time
|
||||
}{
|
||||
{"h1", "#belgium", t1},
|
||||
{"h2", "#belgium", t1},
|
||||
{"h3", "#belgium", t2},
|
||||
{"h4", "#france", t1},
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, err := srv.db.conn.Exec(
|
||||
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,scope_name) VALUES (?,?,?,0,5,?)`,
|
||||
"aa", r.hash, r.ts.Format(time.RFC3339), 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)
|
||||
}
|
||||
|
||||
var belgium, france *ScopeHourlyActivity
|
||||
for i := range resp.HourlyActivityByRegion {
|
||||
switch resp.HourlyActivityByRegion[i].Region {
|
||||
case "#belgium":
|
||||
belgium = &resp.HourlyActivityByRegion[i]
|
||||
case "#france":
|
||||
france = &resp.HourlyActivityByRegion[i]
|
||||
}
|
||||
}
|
||||
if belgium == nil || france == nil {
|
||||
t.Fatalf("hourlyActivityByRegion missing entries: %+v", resp.HourlyActivityByRegion)
|
||||
}
|
||||
if hour1 == hour2 {
|
||||
// Edge case: test ran within a few minutes of an hour rollover, so
|
||||
// t1 and t2 collapsed into the same hour-of-day bucket.
|
||||
if belgium.Hours[hour1] != 3 {
|
||||
t.Errorf("belgium.Hours[%d] = %d, want 3 (hour1==hour2 collapse case)", hour1, belgium.Hours[hour1])
|
||||
}
|
||||
} else {
|
||||
if belgium.Hours[hour1] != 2 {
|
||||
t.Errorf("belgium.Hours[%d] = %d, want 2", hour1, belgium.Hours[hour1])
|
||||
}
|
||||
if belgium.Hours[hour2] != 1 {
|
||||
t.Errorf("belgium.Hours[%d] = %d, want 1", hour2, belgium.Hours[hour2])
|
||||
}
|
||||
}
|
||||
if france.Hours[hour1] != 1 {
|
||||
t.Errorf("france.Hours[%d] = %d, want 1", hour1, france.Hours[hour1])
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -126,6 +126,15 @@ type ScopeTimePoint struct {
|
||||
Unscoped int `json:"unscoped"`
|
||||
}
|
||||
|
||||
// ScopeHourlyActivity is a region's message counts bucketed by hour-of-day
|
||||
// (0-23, UTC), aggregated across every day in the requested window —
|
||||
// "when during a typical day is this region active" rather than "how did
|
||||
// volume change over the window" (that's ScopeTimePoint/TimeSeries).
|
||||
type ScopeHourlyActivity struct {
|
||||
Region string `json:"region"`
|
||||
Hours [24]int `json:"hours"`
|
||||
}
|
||||
|
||||
type ScopeStatsResponse struct {
|
||||
Window string `json:"window"`
|
||||
Summary ScopeStatsSummary `json:"summary"`
|
||||
@@ -159,6 +168,9 @@ type ScopeStatsResponse struct {
|
||||
// literal backbone nodes connecting separate regional communities.
|
||||
// All-time, like RepeatersByRegion (same source data, same caveats).
|
||||
BridgeRepeaters []BridgeRepeater `json:"bridgeRepeaters,omitempty"`
|
||||
// HourlyActivityByRegion is window-scoped like Summary/TimeSeries
|
||||
// above — see ScopeHourlyActivity doc.
|
||||
HourlyActivityByRegion []ScopeHourlyActivity `json:"hourlyActivityByRegion,omitempty"`
|
||||
}
|
||||
|
||||
type RepeaterRef struct {
|
||||
|
||||
@@ -4491,6 +4491,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
'<tbody id="scopes-tbody"></tbody>' +
|
||||
'</table>' +
|
||||
'<div id="scopes-chart"></div>' +
|
||||
'<div id="scopes-hourly" style="margin-top:16px"></div>' +
|
||||
'<div id="scopes-utilization" style="margin-top:16px"></div>' +
|
||||
'<div id="scopes-repeaters" style="margin-top:16px"></div>' +
|
||||
'<div id="scopes-bridges" style="margin-top:16px"></div>' +
|
||||
@@ -4654,6 +4655,48 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
|
||||
chartEl.innerHTML = chartHtml;
|
||||
}
|
||||
|
||||
// Hour-of-day activity per region: when during a typical day is each
|
||||
// region active — a heatmap, not a chronological chart. Color
|
||||
// intensity is normalized PER ROW (each region's own busiest hour),
|
||||
// not globally, so a quiet region's shape is still visible next to
|
||||
// a loud one instead of being crushed to near-zero.
|
||||
var hourlyEl = document.getElementById('scopes-hourly');
|
||||
if (hourlyEl) {
|
||||
var hourly = d.hourlyActivityByRegion || [];
|
||||
if (hourly.length > 0) {
|
||||
var hourLabels = '';
|
||||
for (var hl = 0; hl < 24; hl += 3) {
|
||||
hourLabels += '<div style="flex:3;text-align:left;font-size:9px" title="' + hl + ':00 UTC">' + hl + '</div>';
|
||||
}
|
||||
var hourlyRows = hourly.map(function(ha) {
|
||||
var maxV = Math.max.apply(null, ha.hours.concat([1]));
|
||||
var cells = ha.hours.map(function(v, h) {
|
||||
var alpha = v > 0 ? (0.12 + 0.88 * (v / maxV)) : 0;
|
||||
var bg = v > 0 ? 'background:var(--accent);opacity:' + alpha.toFixed(2) : 'background:var(--border)';
|
||||
return '<div style="flex:1;height:16px;' + bg + '" title="' + esc(ha.region) + ' ' + h + ':00 UTC — ' + v.toLocaleString() + ' msg' + (v === 1 ? '' : 's') + '"></div>';
|
||||
}).join('');
|
||||
return '<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">' +
|
||||
'<div style="width:110px;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(ha.region) + '"><code>' + esc(ha.region) + '</code></div>' +
|
||||
'<div style="flex:1;display:flex;gap:1px">' + cells + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
hourlyEl.innerHTML =
|
||||
'<h4 style="margin:0 0 4px">Activity by Hour of Day</h4>' +
|
||||
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">' +
|
||||
'When during a typical day (UTC) each region is active, aggregated across every day in the window above. Reads best on 7d — color is normalized per region, so quiet and busy regions are both visible.' +
|
||||
'</p>' +
|
||||
'<div style="display:flex;gap:6px;margin-bottom:4px">' +
|
||||
'<div style="width:110px"></div>' +
|
||||
'<div style="flex:1;display:flex">' + hourLabels + '</div>' +
|
||||
'</div>' +
|
||||
hourlyRows;
|
||||
} else {
|
||||
hourlyEl.innerHTML =
|
||||
'<h4 style="margin:0 0 4px">Activity by Hour of Day</h4>' +
|
||||
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">No scoped messages in this window to chart by hour of day.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Region utilization: how much of the configured hashRegions list
|
||||
// has never actually matched anything — all-time (not window-scoped),
|
||||
// so it doesn't fluctuate with the 1h/24h/7d selector above. Only
|
||||
|
||||
Reference in New Issue
Block a user