mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 21:37:36 +00:00
Fixes #661 (the narrowed scope from the 2026-10-14 comment). ## What changes - `GET /api/observers/metrics/summary` adds `tx_airtime_pct_1h` and `rx_airtime_pct_1h` per observer: the airtime share over the hour before now, whatever `window` is, from the cumulative `tx_air_secs` / `rx_air_secs` counters in `observer_metrics`. - A counter that goes down marks a reboot; only the stretch after the last reset counts. - Under 15 minutes of samples (one sample, or a reboot late in the hour) returns `null`. - Clamped to 0..100, rounded to 2 decimals. - The response also carries `duty_cycle_warn_pct`, from the new config key `dutyCycleWarnPct` (default 10, the EU 868 MHz g3 sub-band limit). Added to `config.example.json`. - RF Health tiles show `TX 1h: x%`; the observer list gets a sortable numeric `TX 1h` column. Both use the shared `public/observer-airtime.js` helper and flag values at or over the threshold with `.airtime-over` (`var(--status-red-text)`). - The handler now returns a named `MetricsSummaryResponse` struct instead of `map[string]interface{}` (one occurrence fewer). ## Why the warning matters Repeater and room-server firmware default to `airtime_factor = 1.0` (`examples/simple_repeater/MyMesh.cpp:890`), which `Dispatcher.cpp:26` turns into a 50% TX budget per 1 h window. The device therefore does not keep itself under the EU 10% limit; this view is where an operator can see it. ## Cost - Server: one extra query per summary request, `SELECT observer_id, timestamp, tx_air_secs, rx_air_secs FROM observer_metrics WHERE timestamp >= now-1h`, on the existing `idx_observer_metrics_timestamp`, then one O(rows) pass. On our instance that is 35 observers x about 12 samples (5 min interval). - Observer list: one extra bulk call `summary?window=1h` per load (client TTL 30 s, failure ignored), not a call per observer. ## Tests - Go: `TestRollingAirtimePct` (steady, reboot mid-hour, single sample, span under 15 min, reboot leaving under 15 min, nil counters, clamp, rounding), `TestGetMetricsSummaryAirtime1h` (samples older than 1 h ignored inside a 24 h window, single-sample observer gets null), `TestMetricsSummaryDutyCycleWarnPct` (default 10, config override, `observers` is `[]` not `null`). Full `cmd/server` suite passes locally (skipping `TestSaveGeoFilterPreservesFileMode`, which fails on Windows on clean master too). - Frontend unit: `tests/unit/test-issue-661-observer-airtime.js` (8 assertions, real file in `vm`), added to `test-all.sh`. `test-observers-headings.js` updated for the new column. Full `test-all.sh` passes locally. - E2E: `tests/e2e/test-issue-661-airtime-e2e.js`, registered in `deploy.yml` and `scripts/non-unit-tests.json`. The fixture DB has no `observer_metrics` table, so the summary is served through `page.route` with known values for the fixture's own observer ids. It checks the column header, over/under/unknown cells, sort order and the RF Health tile. 4/4 locally. It caught a real bug during development: `loadRFHealthData` declares a local `const window`, so the tile calls the helper as the global `ObserverAirtime`. ## Validation Deployed to a staging instance with live MQTT data (35 observers with metrics) and checked in the browser: - The observer list shows the column for 42 rows (33 with a value, 9 without enough samples). Sorting puts the highest share (5.3%) first. - RF Health shows `TX 1h` on all 35 tiles. With a fourth stat the line wrapped inside items on narrow tiles; the stats row now wraps between items only. - No observer was over 10% at the time, so the red state was checked by rendering one cell client-side at 12.5% (computed colour `rgb(185, 28, 28)` in light mode), and by the E2E test. ## Not done - The threshold is config-only; exposing it in the customizer is a follow-up. - No airtime comparison across observers (that is #671) and no per-packet airtime (Relay Airtime Share covers it). - Dark mode was not checked in the browser; `--status-red-text` is defined for both themes. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
4.2 KiB
Go
125 lines
4.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func airPt(base time.Time, minAgo int, secs int) airtimePoint {
|
|
v := secs
|
|
return airtimePoint{At: base.Add(-time.Duration(minAgo) * time.Minute), Secs: &v}
|
|
}
|
|
|
|
func pctOrNil(p *float64) interface{} {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
return *p
|
|
}
|
|
|
|
func TestRollingAirtimePct(t *testing.T) {
|
|
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
|
cases := []struct {
|
|
name string
|
|
pts []airtimePoint
|
|
want interface{}
|
|
}{
|
|
{"steady 10%", []airtimePoint{airPt(now, 60, 1000), airPt(now, 30, 1180), airPt(now, 0, 1360)}, 10.0},
|
|
{"reboot mid-hour uses the segment after the reset",
|
|
[]airtimePoint{airPt(now, 60, 0), airPt(now, 40, 100), airPt(now, 30, 5), airPt(now, 0, 185)}, 10.0},
|
|
{"single sample", []airtimePoint{airPt(now, 0, 50)}, nil},
|
|
{"no samples", nil, nil},
|
|
{"span below 30 minutes", []airtimePoint{airPt(now, 25, 0), airPt(now, 0, 150)}, nil},
|
|
{"span of exactly 30 minutes", []airtimePoint{airPt(now, 30, 0), airPt(now, 0, 180)}, 10.0},
|
|
{"reboot leaves less than 30 minutes", []airtimePoint{airPt(now, 60, 0), airPt(now, 30, 400), airPt(now, 25, 2), airPt(now, 0, 20)}, nil},
|
|
{"newest sample older than 15 minutes", []airtimePoint{airPt(now, 60, 0), airPt(now, 20, 240)}, nil},
|
|
{"newest sample exactly 15 minutes old", []airtimePoint{airPt(now, 60, 0), airPt(now, 15, 270)}, 10.0},
|
|
{"nil counters are skipped",
|
|
[]airtimePoint{airPt(now, 60, 0), {At: now.Add(-30 * time.Minute)}, airPt(now, 0, 36)}, 1.0},
|
|
{"clamped to 100", []airtimePoint{airPt(now, 30, 0), airPt(now, 0, 5000)}, 100.0},
|
|
{"rounded to 2 decimals", []airtimePoint{airPt(now, 60, 0), airPt(now, 0, 1)}, 0.03},
|
|
}
|
|
for _, c := range cases {
|
|
got := pctOrNil(rollingAirtimePct(c.pts, now))
|
|
if got != c.want {
|
|
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetMetricsSummaryAirtime1h(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
seedTestData(t, db)
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
ins := func(obs string, minAgo, tx, rx int) {
|
|
ts := now.Add(-time.Duration(minAgo) * time.Minute).Format(time.RFC3339)
|
|
if _, err := db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs) VALUES (?, ?, ?, ?, ?)",
|
|
obs, ts, -110.0, tx, rx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// obs1: a sample 3 h ago with a far lower counter must not count; inside
|
|
// the hour it transmits 180 s in 30 min (10%) and receives 360 s (20%).
|
|
ins("obs1", 180, 0, 0)
|
|
ins("obs1", 30, 5000, 9000)
|
|
ins("obs1", 0, 5180, 9360)
|
|
// obs2: only one sample inside the hour, so no rolling figure.
|
|
ins("obs2", 120, 0, 0)
|
|
ins("obs2", 5, 900, 900)
|
|
// obs3: a full hour at 10%, but silent for the last 40 minutes.
|
|
ins("obs3", 60, 0, 0)
|
|
ins("obs3", 40, 120, 120)
|
|
|
|
summary, err := db.GetMetricsSummary(now.Add(-24*time.Hour).Format(time.RFC3339), now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
byID := map[string]MetricsSummaryRow{}
|
|
for _, r := range summary {
|
|
byID[r.ObserverID] = r
|
|
}
|
|
if got := pctOrNil(byID["obs1"].TxAirtimePct1h); got != 10.0 {
|
|
t.Errorf("obs1 tx 1h = %v, want 10", got)
|
|
}
|
|
if got := pctOrNil(byID["obs1"].RxAirtimePct1h); got != 20.0 {
|
|
t.Errorf("obs1 rx 1h = %v, want 20", got)
|
|
}
|
|
if got := pctOrNil(byID["obs2"].TxAirtimePct1h); got != nil {
|
|
t.Errorf("obs2 tx 1h = %v, want nil", got)
|
|
}
|
|
if _, ok := byID["obs3"]; !ok {
|
|
t.Fatal("obs3 missing from the summary")
|
|
}
|
|
if got := pctOrNil(byID["obs3"].TxAirtimePct1h); got != nil {
|
|
t.Errorf("obs3 (stale) tx 1h = %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
func TestMetricsSummaryDutyCycleWarnPct(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
get := func() float64 {
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, httptest.NewRequest("GET", "/api/observers/metrics/summary?window=1h", nil))
|
|
if w.Code != 200 {
|
|
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp MetricsSummaryResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Observers == nil {
|
|
t.Fatal("observers must be [] not null")
|
|
}
|
|
return resp.DutyCycleWarnPct
|
|
}
|
|
if got := get(); got != 10 {
|
|
t.Errorf("default duty-cycle warn = %v, want 10", got)
|
|
}
|
|
srv.cfg.DutyCycleWarnPct = 1
|
|
if got := get(); got != 1 {
|
|
t.Errorf("configured duty-cycle warn = %v, want 1", got)
|
|
}
|
|
}
|