diff --git a/.eslintrc.json b/.eslintrc.json index 03a4a5099..ec551920f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -35,6 +35,7 @@ "Logo": "readonly", "MAX_HOP_DIST": "readonly", "MeshAudio": "readonly", + "ObserverAirtime": "readonly", "MeshConfigReady": "readonly", "NodeAdverts": "readonly", "PAYLOAD_COLORS": "readonly", diff --git a/cmd/server/config.go b/cmd/server/config.go index b850a397c..b05417fb2 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -357,6 +357,10 @@ type Config struct { // BatteryThresholds: voltage cutoffs for low/critical alerts (#663). BatteryThresholds *BatteryThresholdsConfig `json:"batteryThresholds,omitempty"` + // DutyCycleWarnPct: rolling 1 h TX airtime share (percent) at which the + // RF Health view and observer list warn. 0/missing => 10 (EU g3, #479). + DutyCycleWarnPct float64 `json:"dutyCycleWarnPct,omitempty"` + // Customizer controls operator-side knobs for the in-app customizer modal // (theme/branding/etc.). See CustomizerConfig and issue #1508. Customizer *CustomizerConfig `json:"customizer,omitempty"` diff --git a/cmd/server/db.go b/cmd/server/db.go index 0a32a6370..6684cc9a7 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -5156,12 +5156,16 @@ type MetricsSummaryRow struct { CurrentBattMv *int `json:"battery_mv"` SampleCount int `json:"sample_count"` Sparkline []*float64 `json:"sparkline"` + // Airtime share over the hour before now, whatever the window (#479). + TxAirtimePct1h *float64 `json:"tx_airtime_pct_1h"` + RxAirtimePct1h *float64 `json:"rx_airtime_pct_1h"` } // GetMetricsSummary returns a fleet summary of observer metrics within a time window. // Uses a CTE with ROW_NUMBER to get latest values in a single pass (no correlated subqueries). -// Also returns sparkline data (noise_floor time series) per observer. -func (db *DB) GetMetricsSummary(since string) ([]MetricsSummaryRow, error) { +// Also returns sparkline data (noise_floor time series) per observer, and the +// TX/RX airtime share over the hour before now. +func (db *DB) GetMetricsSummary(since string, now time.Time) ([]MetricsSummaryRow, error) { query := ` WITH ranked AS ( SELECT observer_id, noise_floor, battery_mv, @@ -5228,11 +5232,51 @@ func (db *DB) GetMetricsSummary(since string) ([]MetricsSummaryRow, error) { result[i].Sparkline = s } } + + if err := db.fillAirtime1h(result, now); err != nil { + return nil, err + } } return result, nil } +// fillAirtime1h sets the rolling 1 h TX/RX airtime share on each row from +// the cumulative counters sampled in the hour before now, in one query. +func (db *DB) fillAirtime1h(rows []MetricsSummaryRow, now time.Time) error { + since := now.Add(-airtimeWindow).Format(time.RFC3339) + q, err := db.conn.Query(`SELECT observer_id, timestamp, tx_air_secs, rx_air_secs + FROM observer_metrics WHERE timestamp >= ? ORDER BY observer_id, timestamp ASC`, since) + if err != nil { + return err + } + defer q.Close() + + tx := make(map[string][]airtimePoint) + rx := make(map[string][]airtimePoint) + for q.Next() { + var oid, ts string + var txSecs, rxSecs *int + if err := q.Scan(&oid, &ts, &txSecs, &rxSecs); err != nil { + return err + } + at, err := time.Parse(time.RFC3339, ts) + if err != nil { + continue + } + tx[oid] = append(tx[oid], airtimePoint{At: at, Secs: txSecs}) + rx[oid] = append(rx[oid], airtimePoint{At: at, Secs: rxSecs}) + } + if err := q.Err(); err != nil { + return err + } + for i := range rows { + rows[i].TxAirtimePct1h = rollingAirtimePct(tx[rows[i].ObserverID], now) + rows[i].RxAirtimePct1h = rollingAirtimePct(rx[rows[i].ObserverID], now) + } + return nil +} + // (PruneOldMetrics / RemoveStaleObservers removed in #1283 — see note // above the MetricsSample type. Ingestor owns these writes now.) diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 5cfce1443..b592a1d0d 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -2922,7 +2922,7 @@ func TestGetMetricsSummary(t *testing.T) { "obs2", t1, -115.0) since := now.Add(-24 * time.Hour).Format(time.RFC3339) - summary, err := db.GetMetricsSummary(since) + summary, err := db.GetMetricsSummary(since, now) if err != nil { t.Fatal(err) } diff --git a/cmd/server/observer_airtime.go b/cmd/server/observer_airtime.go new file mode 100644 index 000000000..1f6c12264 --- /dev/null +++ b/cmd/server/observer_airtime.go @@ -0,0 +1,81 @@ +package main + +import ( + "math" + "time" +) + +// Rolling 1 h airtime per observer (#479, upstream #661). The firmware enforces its own +// duty-cycle budget over a 1 h window (Dispatcher.cpp, duty_cycle_window_ms), +// but repeaters default to airtime_factor 1.0, i.e. a 50% budget, so the EU +// 10% limit is not enforced on the device. The summary therefore reports the +// TX share of the last hour and the threshold the UI warns at. + +// airtimeWindow is the span the rolling figure covers, matching the +// firmware's duty-cycle window. +const airtimeWindow = time.Hour + +// airtimeMinSpan is the shortest stretch of samples that yields a figure; +// shorter (one sample, or a reboot late in the hour) reads as unknown. The +// ingestor rounds sample times to its sample interval (5 min by default), so +// the span can be off by up to one interval: about 17% at 30 min, 8% at 1 h. +const airtimeMinSpan = 30 * time.Minute + +// airtimeMaxAge is how old the newest sample may be; an observer that has +// gone quiet longer than this reads as unknown rather than "last hour". +const airtimeMaxAge = 15 * time.Minute + +// defaultDutyCycleWarnPct is the EU 868 MHz g3 sub-band duty-cycle limit. +const defaultDutyCycleWarnPct = 10.0 + +// airtimePoint is one sample of a cumulative airtime counter (seconds since +// boot); Secs is nil when the observer did not report it. +type airtimePoint struct { + At time.Time + Secs *int +} + +// rollingAirtimePct returns the airtime share, in percent, over the points +// given in time order. A counter that goes down marks a reboot: only the +// stretch after the last reset counts. Returns nil when that stretch is +// shorter than airtimeMinSpan, or the newest sample is older than +// airtimeMaxAge at now. +func rollingAirtimePct(pts []airtimePoint, now time.Time) *float64 { + var start, last *airtimePoint + for i := range pts { + p := &pts[i] + if p.Secs == nil { + continue + } + if start == nil || *p.Secs < *last.Secs { + start = p + } + last = p + } + if start == nil || now.Sub(last.At) > airtimeMaxAge { + return nil + } + span := last.At.Sub(start.At) + if span < airtimeMinSpan { + return nil + } + pct := float64(*last.Secs-*start.Secs) / span.Seconds() * 100 + pct = math.Min(math.Max(pct, 0), 100) + pct = math.Round(pct*100) / 100 + return &pct +} + +// DutyCycleWarnPctOrDefault returns the configured duty-cycle warning +// threshold in percent, or the EU 10% default. +func (c *Config) DutyCycleWarnPctOrDefault() float64 { + if c != nil && c.DutyCycleWarnPct > 0 { + return c.DutyCycleWarnPct + } + return defaultDutyCycleWarnPct +} + +// MetricsSummaryResponse is the body of GET /api/observers/metrics/summary. +type MetricsSummaryResponse struct { + Observers []MetricsSummaryRow `json:"observers"` + DutyCycleWarnPct float64 `json:"duty_cycle_warn_pct"` +} diff --git a/cmd/server/observer_airtime_test.go b/cmd/server/observer_airtime_test.go new file mode 100644 index 000000000..a0c151546 --- /dev/null +++ b/cmd/server/observer_airtime_test.go @@ -0,0 +1,124 @@ +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) + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index ef4f0e814..82a325f69 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -215,7 +215,7 @@ func routeDescriptions() map[string]routeMeta { "GET /api/observers/{id}/analytics": {Summary: "Get observer analytics", Tag: "observers"}, "GET /api/observers/{id}/neighbors": {Summary: "Get an observer's direct (zero-hop) neighbors", Description: "Ground truth from the observer's own /neighbors firmware report (#1865) -- distinct from the packet-path-inferred neighbor graph. Empty `neighbors` (never null) and an empty `reportedAt` mean the observer has never sent a /neighbors report: opt-in firmware, unavailable on non-PSRAM hardware -- absence is normal, not a fault. Each entry's `scopes` is null unless the neighbor's OTA scope query responded (status=\"responded\"); `name`/`role` are null when the pubkey doesn't resolve to a known node. `seenViaPackets` cross-references the packet-path-inferred neighbor_edges graph: false means this firmware-confirmed neighbor has never had a resolved packet path between it and the observer, a diagnostic signal (possible coverage gap or packet loss), not itself a fault.", Tag: "observers"}, "GET /api/observers/{id}/neighbors/{pubkey}/metrics": {Summary: "Get SNR history for one observer<->neighbor direct-RF link", Description: "Raw (unaggregated) history of the snr/heard_secs_ago fields the observer's own /neighbors report carries per neighbor -- report volume per pair is inherently low so, unlike /api/observers/{id}/metrics, there is no resolution/downsampling. Defaults to the last 30 days.", Tag: "observers", QueryParams: []paramMeta{{Name: "since", Description: "RFC3339 lower bound (default: 30 days ago)", Type: "string"}, {Name: "until", Description: "RFC3339 upper bound (default: none)", Type: "string"}}}, - "GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers.", Tag: "observers"}, + "GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers within window (noise floor, battery, sparkline). Each row also carries tx_airtime_pct_1h / rx_airtime_pct_1h: the airtime share over the last hour regardless of window, null with under 30 minutes of samples since the last reboot or no sample in the last 15 minutes. duty_cycle_warn_pct is the configured warning threshold (dutyCycleWarnPct, default 10).", Tag: "observers", QueryParams: []paramMeta{{Name: "window", Description: "Lookback such as 1h, 24h, 7d (default 24h)", Type: "string"}, {Name: "region", Description: "IATA code to filter observers", Type: "string"}}}, "GET /api/observers/neighbors": {Summary: "Every observer's reported direct neighbors, network-wide", Description: "Flattens /api/observers/{id}/neighbors across ALL observers into one list -- Tools > Observer Neighbors. Same per-entry semantics (scopes null unless the OTA scope query responded, seenViaPackets cross-references the packet-derived neighbor_edges graph, observer/neighbor name null when unresolved). Blacklisted observers (config.json observerBlacklist) are excluded. Empty list (not an error) when no observer has ever sent a /neighbors report. unknownScopes surfaces region-scope names that turned up in a reported neighbor scope list but aren't part of this deployment's configured hashRegions -- scopes the mesh is using that CoreScope doesn't know about yet.", Tag: "observers", Response: schemaRef("AllObserverNeighborsResponse")}, "GET /api/analytics/new-nodes": {Summary: "Most recently first-seen nodes, network-wide", Description: "Tools > New Nodes. Newest first, each with its configured area(s) (same touched-areas matching View Path/Ping Scores' Area Activity draw from). Excludes nodes that also appear in inactive_nodes -- a node pruned for inactivity and later returning would otherwise get a freshly-INSERTed nodes.first_seen and misleadingly look brand new; that \"came back after being pruned\" case is its own distinct signal, not covered by this feed. Blacklisted nodes (config.json nodeBlacklist) are excluded. Empty list (not an error) when nothing qualifies.", Tag: "nodes", diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 00d8a5675..849b5ea8b 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -4398,8 +4398,9 @@ func (s *Server) handleMetricsSummary(w http.ResponseWriter, r *http.Request) { return } - since := time.Now().UTC().Add(-dur).Format(time.RFC3339) - summary, err := s.db.GetMetricsSummary(since) + now := time.Now().UTC() + since := now.Add(-dur).Format(time.RFC3339) + summary, err := s.db.GetMetricsSummary(since, now) if err != nil { writeError(w, 500, err.Error()) return @@ -4419,8 +4420,9 @@ func (s *Server) handleMetricsSummary(w http.ResponseWriter, r *http.Request) { summary = filtered } - writeJSON(w, map[string]interface{}{ - "observers": summary, + writeJSON(w, MetricsSummaryResponse{ + Observers: summary, + DutyCycleWarnPct: s.cfg.DutyCycleWarnPctOrDefault(), }) } diff --git a/config.example.json b/config.example.json index 065c45845..20350f852 100644 --- a/config.example.json +++ b/config.example.json @@ -401,6 +401,8 @@ "criticalMv": 3000, "_comment": "Voltage cutoffs (millivolts) for the per-node battery trend chart on /node-analytics. Latest sample below lowMv shows the node as âš ī¸ Low; below criticalMv shows đŸĒĢ Critical. Both default to 3300 / 3000 if omitted. Source data: observer_metrics.battery_mv populated from observer status messages; only nodes that are themselves observers (matching pubkey ↔ observer id) yield a series. Issue #663." }, + "dutyCycleWarnPct": 10, + "_comment_dutyCycleWarnPct": "TX airtime share (percent) over the last hour at which RF Health and the observer list flag an observer. Default 10, the EU 868 MHz g3 sub-band limit; raise it (e.g. 50) elsewhere. Repeater firmware defaults to a 50% budget (airtime_factor 1.0), so the device does not enforce this itself. Issue #479.", "_comment_mqttSources": "Each source connects to an MQTT broker. Supported schemes: mqtt:// (plain TCP), mqtts:// (TLS), ws:// (WebSocket), wss:// (WebSocket TLS). topics: what to subscribe to. iataFilter: only ingest packets from these regions (optional). region: default IATA region for this source — used when packet/topic doesn't specify one (optional, priority: payload > topic > this field). clientId: MQTT client ID, used verbatim (optional; must be unique among the broker's concurrent clients, and at most 23 characters of [0-9A-Za-z] for strict MQTT 3.1 brokers). When omitted, each ingestor start generates corescope--<8 random hex>, so copies of this file never share an ID — do not add a fixed clientId here.", "compression": { "gzip": false, diff --git a/public/analytics.js b/public/analytics.js index f3c3f437e..355724dd1 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -4543,6 +4543,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopScopeAudit( avg: ${avgNf} max: ${maxNf} ${obs.sample_count} samples + TX 1h: ${ObserverAirtime.badge(obs.tx_airtime_pct_1h, summaryData.duty_cycle_warn_pct)} `; }).join(''); @@ -4680,7 +4681,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopScopeAudit( ${hasBattery ? '
' : ''}
- NF: ${curNf} dBm | avg: ${avgNf} | min: ${minNf} | max: ${maxNf} | TX: ${curTx} | RX: ${curRx} | Err: ${curErr} | Batt: ${curBatt}${reboots.length ? ' | ' + reboots.length + ' reboots' : ''} + NF: ${curNf} dBm | avg: ${avgNf} | min: ${minNf} | max: ${maxNf} | TX/sample: ${curTx} | RX/sample: ${curRx} | Err: ${curErr} | Batt: ${curBatt}${reboots.length ? ' | ' + reboots.length + ' reboots' : ''}
`; // Close button diff --git a/public/index.html b/public/index.html index f437b56db..91a3b5d91 100644 --- a/public/index.html +++ b/public/index.html @@ -227,6 +227,7 @@ + diff --git a/public/observer-airtime.js b/public/observer-airtime.js new file mode 100644 index 000000000..d9be20e1e --- /dev/null +++ b/public/observer-airtime.js @@ -0,0 +1,36 @@ +/* #479 — rolling 1 h TX airtime share per observer, shared by the RF Health + * tiles (analytics.js) and the observer list (observers.js). The value and + * the warning threshold come from /api/observers/metrics/summary + * (tx_airtime_pct_1h, duty_cycle_warn_pct). */ +window.ObserverAirtime = (function () { + 'use strict'; + var DEFAULT_WARN_PCT = 10; + + function warnPct(w) { + return w > 0 ? w : DEFAULT_WARN_PCT; + } + + function format(pct) { + if (pct == null) return '—'; + var n = Number(pct); + if (n > 0 && n < 0.1) return '<0.1%'; + return n.toFixed(1) + '%'; + } + + // Compared at the one decimal format() shows, so "10.0%" is never unflagged. + function isOver(pct, w) { + return pct != null && Math.round(Number(pct) * 10) / 10 >= warnPct(w); + } + + function badge(pct, w) { + if (pct == null) { + return '—'; + } + var over = isOver(pct, w); + var title = 'TX airtime over the last hour. Duty-cycle warning at ' + warnPct(w) + '%'; + return '' + + format(pct).replace('<', '<') + ''; + } + + return { format: format, isOver: isOver, badge: badge }; +})(); diff --git a/public/observers.js b/public/observers.js index 510f79807..8bf1c22a8 100644 --- a/public/observers.js +++ b/public/observers.js @@ -140,6 +140,8 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo let _fetchedAt = 0; // #1562: ms epoch when the current `observers` payload was received let _loadObserversReqId = 0; // #1563: monotonic id; resolutions older than the latest are discarded let obsSkewMap = {}; // observerID → {offsetSec, samples} + let obsAirtimeMap = {}; // #479 observerID → tx_airtime_pct_1h + let obsAirtimeWarnPct; // #479 duty_cycle_warn_pct from the summary let wsHandler = null; let refreshTimer = null; let regionChangeHandler = null; @@ -265,6 +267,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo _mqttPanelTeardown = null; observers = []; obsSkewMap = {}; + obsAirtimeMap = {}; } async function loadObservers(opts) { @@ -275,13 +278,20 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo // (or vice versa) with stale data + a misleading "0s ago" pill. var myId = ++_loadObserversReqId; try { - const [data, skewData] = await Promise.all([ + const [data, skewData, airData] = await Promise.all([ api('/observers', { ttl: CLIENT_TTL.observers, bust: bust }), - api('/observers/clock-skew', { ttl: 30000 }).catch(function() { return []; }) + api('/observers/clock-skew', { ttl: 30000 }).catch(function() { return []; }), + // #479 — rolling 1 h TX airtime; one bulk call, not per observer. + api('/observers/metrics/summary?window=1h', { ttl: 30000 }).catch(function() { return {}; }) ]); if (myId !== _loadObserversReqId) return; // stale resolve, newer in-flight observers = data.observers || []; _fetchedAt = Date.now(); // #1562: stamp freshness for the header label + obsAirtimeMap = {}; + obsAirtimeWarnPct = airData && airData.duty_cycle_warn_pct; + ((airData && airData.observers) || []).forEach(function(a) { + if (a && a.observer_id) obsAirtimeMap[a.observer_id] = a.tx_airtime_pct_1h; + }); obsSkewMap = {}; (Array.isArray(skewData) ? skewData : []).forEach(function(s) { if (s && s.observerID) obsSkewMap[s.observerID] = s; @@ -407,7 +417,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo Observer status and statistics StatusNameRegionLast StatusLast Packet - Packet HealthTotal PacketsPackets/HourClock OffsetUptimeFirmwareClientNeighbors + Packet HealthTotal PacketsPackets/HourTX 1hClock OffsetUptimeFirmwareClientNeighbors Select for compare ${filtered.map(o => { @@ -443,6 +453,7 @@ window.preserveCompareSelection = function preserveCompareSelection(prevIds, tbo ${packetBadge(o)} ${(o.packet_count || 0).toLocaleString()} ${sparkBar(o.packetsLastHour || 0, maxPktsHr)} + ${window.ObserverAirtime.badge(obsAirtimeMap[o.id], obsAirtimeWarnPct)} ${(function() { var sk = obsSkewMap[o.id]; if (!sk || sk.samples == null || sk.samples === 0) return '—'; diff --git a/public/style.css b/public/style.css index e0a290feb..6b8020e32 100644 --- a/public/style.css +++ b/public/style.css @@ -4468,6 +4468,11 @@ button.region-pill-active:hover { opacity: 0.85; color: var(--text-on-accent); } .rf-cell-sparkline { height: 24px; margin: 2px 0; overflow: hidden; } .rf-cell-stats { display: flex; gap: 8px; font-size: 10px; color: var(--text-muted); } +/* #479 — a fourth stat ("TX 1h") wraps between items, never inside one */ +.rf-cell-stats { flex-wrap: wrap; row-gap: 0; } +.rf-cell-stats > span { white-space: nowrap; } +/* #479 — rolling 1 h TX airtime at or over the duty-cycle threshold */ +.airtime-over { color: var(--danger); font-weight: 600; } /* Side panel for observer detail */ .rf-health-detail { diff --git a/scripts/e2e-shards.json b/scripts/e2e-shards.json index 5b79fa183..8c28b8fa3 100644 --- a/scripts/e2e-shards.json +++ b/scripts/e2e-shards.json @@ -254,6 +254,14 @@ 14.602 ] }, + { + "file": "test-issue-479-airtime-e2e.js", + "shard": 3, + "env": { "BASE_URL": "$BASE_URL" }, + "output": "e2e", + "meanSeconds": 0, + "measurementNote": "New suite; no CI duration measured yet. Placed beside the sibling observers-sort suite." + }, { "file": "test-issue-1639-observers-sort-e2e.js", "shard": 3, diff --git a/test-all.sh b/test-all.sh index c4610234c..8877e1855 100755 --- a/test-all.sh +++ b/test-all.sh @@ -194,6 +194,7 @@ run test-issue-1407-cb-preset-propagation.js run test-issue-1412-customizer-no-override.js run test-issue-1846-observers-width.js run test-issue-1562-observers-summary.js +run test-issue-479-observer-airtime.js run test-issue-1509-nav-active-bg.js run test-issue-1509-detect-preset.js run test-issue-353-path-hash-warn.js diff --git a/test-issue-479-airtime-e2e.js b/test-issue-479-airtime-e2e.js new file mode 100644 index 000000000..b70411192 --- /dev/null +++ b/test-issue-479-airtime-e2e.js @@ -0,0 +1,159 @@ +/** + * E2E test (#479): rolling 1 h TX airtime per observer. + * + * The fixture DB has no observer_metrics inside the last hour, so the + * summary endpoint is intercepted with page.route and given known values for + * the fixture's own observer ids: one over the duty-cycle threshold, one + * under, the rest unknown. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-661-airtime-e2e.js + */ +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +async function test(name, fn) { + try { + await fn(); + console.log(` ✅ ${name}`); + } catch (err) { + console.log(` ❌ ${name}: ${err.message}`); + process.exit(1); + } +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg || 'Assertion failed'); +} + +function summaryFor(ids, overId, underId) { + return { + duty_cycle_warn_pct: 10, + observers: ids.map((id) => ({ + observer_id: id, + observer_name: id, + current_noise_floor: -110, + avg_noise_floor_24h: -111, + max_noise_floor_24h: -105, + battery_mv: null, + sample_count: 12, + sparkline: [-112, -110, -108], + tx_airtime_pct_1h: id === overId ? 12.5 : (id === underId ? 2 : null), + rx_airtime_pct_1h: null, + })), + }; +} + +async function run() { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] + }); + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + + console.log(`\nRunning #479 observer airtime E2E tests against ${BASE}\n`); + + const obsResp = await page.request.get(`${BASE}/api/observers`); + const ids = ((await obsResp.json()).observers || []).map((o) => o.id); + assert(ids.length >= 2, `fixture needs >=2 observers, got ${ids.length}`); + const [overId, underId] = ids; + + await page.route('**/api/observers/metrics/summary*', (route) => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(summaryFor(ids, overId, underId)), + })); + + await test('observer list has a sortable numeric TX 1h column', async () => { + await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { try { localStorage.removeItem('meshcore-observers-sort'); } catch (_) {} }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#obsTable tbody tr'); + const th = await page.$('#obsTable thead th[data-sort-key="airtime_1h"]'); + assert(th, 'TX 1h must carry data-sort-key="airtime_1h"'); + assert(await th.getAttribute('data-type') === 'numeric', 'TX 1h must be data-type="numeric"'); + }); + + await test('over-threshold observer is flagged, under-threshold is not, unknown is a dash', async () => { + const cell = (id) => page.$eval(`#obsTable tbody tr[data-observer-id="${id}"] td[data-testid="obs-cell-airtime"]`, + (td) => ({ html: td.innerHTML, text: td.textContent.trim(), value: td.getAttribute('data-value') })); + const over = await cell(overId); + assert(over.html.includes('airtime-over') && over.text === '12.5%', `over cell: ${JSON.stringify(over)}`); + const under = await cell(underId); + assert(!under.html.includes('airtime-over') && under.text === '2.0%', `under cell: ${JSON.stringify(under)}`); + if (ids.length > 2) { + const unknown = await cell(ids[2]); + assert(unknown.text === '—' && unknown.value === '', `unknown cell: ${JSON.stringify(unknown)}`); + } + }); + + for (const theme of ['light', 'dark']) { + await test(`warning is styled from --danger in the ${theme} theme`, async () => { + await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#obsTable tbody tr'); + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme); + const colors = await page.evaluate(([o, u]) => { + const c = (id) => getComputedStyle(document.querySelector( + `#obsTable tbody tr[data-observer-id="${id}"] td[data-testid="obs-cell-airtime"] .airtime-pct`)).color; + const probe = document.createElement('span'); + probe.style.color = 'var(--danger)'; + document.body.appendChild(probe); + const danger = getComputedStyle(probe).color; + probe.remove(); + return { over: c(o), under: c(u), danger }; + }, [overId, underId]); + assert(colors.over === colors.danger, `${theme}: over ${colors.over} != --danger ${colors.danger}`); + assert(colors.under !== colors.over, `${theme}: under and over share colour ${colors.over}`); + }); + } + + await test('clicking TX 1h sorts the highest share first', async () => { + await page.click('#obsTable thead th[data-sort-key="airtime_1h"]'); + const first = await page.$eval('#obsTable tbody tr', (tr) => tr.getAttribute('data-observer-id')); + assert(first === overId, `first row after sort = ${first}, want ${overId}`); + }); + + await test('clicking TX 1h again reverses the order of the known values', async () => { + await page.click('#obsTable thead th[data-sort-key="airtime_1h"]'); + const rows = await page.$$eval('#obsTable tbody tr', (trs) => trs.map((tr) => ({ + id: tr.getAttribute('data-observer-id'), + v: tr.querySelector('td[data-testid="obs-cell-airtime"]').getAttribute('data-value'), + }))); + // Where unknown values land is TableSort's shared business; the known ones must ascend. + const known = rows.filter((r) => r.v !== ''); + assert(known.length >= 2, `need two known values, got ${JSON.stringify(rows)}`); + assert(known[0].id === underId && known[known.length - 1].id === overId, + `known values ascending = ${known.map((r) => r.v)}`); + }); + + await test('RF Health tiles show the TX 1h share with the warning', async () => { + await page.goto(`${BASE}/#/analytics?tab=rf-health`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.rf-cell'); + const over = await page.$eval(`.rf-cell[data-observer="${overId}"] [data-testid="rf-cell-airtime"]`, + (el) => ({ html: el.innerHTML, text: el.textContent.trim() })); + assert(over.html.includes('airtime-over') && over.text === 'TX 1h: 12.5%', `tile: ${JSON.stringify(over)}`); + }); + + await test('360x800: TX 1h tile stat stays inside its tile', async () => { + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto(`${BASE}/#/analytics?tab=rf-health`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.rf-cell'); + const m = await page.$eval(`.rf-cell[data-observer="${overId}"]`, (cell) => { + const box = cell.getBoundingClientRect(); + const el = cell.querySelector('[data-testid="rf-cell-airtime"]'); + const stat = el.getBoundingClientRect(); + return { inside: stat.right <= box.right + 1 && stat.left >= box.left - 1, + oneLine: stat.height <= parseFloat(getComputedStyle(el).fontSize) * 1.8 }; + }); + assert(m.inside, 'TX 1h stat overflows its tile at 360px'); + assert(m.oneLine, 'TX 1h stat wraps inside itself at 360px'); + }); + + await browser.close(); + console.log('\nAll #479 airtime E2E tests passed.'); +} + +run().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test-issue-479-observer-airtime.js b/test-issue-479-observer-airtime.js new file mode 100644 index 000000000..a46e1191c --- /dev/null +++ b/test-issue-479-observer-airtime.js @@ -0,0 +1,78 @@ +/** + * #479 — rolling 1 h TX airtime per observer: the shared badge helper used + * by the RF Health tiles (analytics.js) and the observer list (observers.js). + * Loads the real public/observer-airtime.js in a vm sandbox. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const assert = require('assert'); + +const REPO_ROOT = __dirname; +let passed = 0, failed = 0; +function t(name, fn) { + try { fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} + +const ctx = { window: {}, Number, Math, String }; +vm.createContext(ctx); +vm.runInContext(fs.readFileSync(path.join(REPO_ROOT, 'public', 'observer-airtime.js'), 'utf8'), ctx); +const A = ctx.window.ObserverAirtime; + +console.log('observer-airtime.js'); + +t('exposes window.ObserverAirtime', () => { + assert.ok(A && typeof A.format === 'function' && typeof A.isOver === 'function' && typeof A.badge === 'function'); +}); + +t('format: null and undefined read as unknown', () => { + assert.strictEqual(A.format(null), '—'); + assert.strictEqual(A.format(undefined), '—'); +}); + +t('format: one decimal, tiny non-zero shares as <0.1%', () => { + assert.strictEqual(A.format(0), '0.0%'); + assert.strictEqual(A.format(0.03), '<0.1%'); + assert.strictEqual(A.format(5.18), '5.2%'); + assert.strictEqual(A.format(100), '100.0%'); +}); + +t('isOver: at or above the threshold warns, unknown never does', () => { + assert.strictEqual(A.isOver(10, 10), true); + assert.strictEqual(A.isOver(9.9, 10), false); + assert.strictEqual(A.isOver(null, 10), false); +}); + +t('isOver: a share that displays as the threshold warns', () => { + assert.strictEqual(A.format(9.96), '10.0%'); + assert.strictEqual(A.isOver(9.96, 10), true); + assert.strictEqual(A.isOver(9.94, 10), false); +}); + +t('isOver: a missing threshold falls back to 10%', () => { + assert.strictEqual(A.isOver(10, undefined), true); + assert.strictEqual(A.isOver(9, 0), false); +}); + +t('badge: over threshold carries airtime-over and names the limit', () => { + const html = A.badge(12.5, 10); + assert.ok(/class="airtime-pct airtime-over"/.test(html), html); + assert.ok(html.includes('12.5%'), html); + assert.ok(html.includes('10%'), html); +}); + +t('badge: under threshold has no warning class', () => { + const html = A.badge(2, 10); + assert.ok(!html.includes('airtime-over'), html); + assert.ok(html.includes('2.0%'), html); +}); + +t('badge: unknown renders a muted dash', () => { + const html = A.badge(null, 10); + assert.ok(html.includes('text-muted') && html.includes('—'), html); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/test-observers-headings.js b/test-observers-headings.js index 3d4bf2080..249b18deb 100644 --- a/test-observers-headings.js +++ b/test-observers-headings.js @@ -58,7 +58,7 @@ test('expected headings present and ordered', () => { let m; while ((m = re.exec(thead)) !== null) labels.push(m[1].trim()); const expected = ['Status', 'Name', 'Region', 'Last Status', 'Last Packet', - 'Packet Health', 'Total Packets', 'Packets/Hour', 'Clock Offset', 'Uptime', + 'Packet Health', 'Total Packets', 'Packets/Hour', 'TX 1h', 'Clock Offset', 'Uptime', 'Firmware', 'Client', 'Neighbors']; assert.deepStrictEqual(labels, expected, `Headings out of sync.\nGot: ${JSON.stringify(labels)}\nExpected: ${JSON.stringify(expected)}`); diff --git a/test-xss-escape-sinks.js b/test-xss-escape-sinks.js index 7cfa8e626..6b7cec53a 100644 --- a/test-xss-escape-sinks.js +++ b/test-xss-escape-sinks.js @@ -786,12 +786,18 @@ test('#333 RF-health cell escapes observer_id in attributes', () => { assert.ok(m, '#333 RF-health cell template not found'); const fn = new Function( 'obs', 'isSelected', 'esc', 'nf', 'avgNf', 'maxNf', 'batt', 'nfClass', 'name', + 'ObserverAirtime', 'summaryData', 'return `' + m[1] + '`;'); + // The tile's TX 1h stat comes from the real shared helper (#479). + const airCtx = { window: {} }; + vm.createContext(airCtx); + vm.runInContext(fs.readFileSync('public/observer-airtime.js', 'utf8'), airCtx); // Double-quote attribute breakout attempt on observer_id. const dqPayload = '">'; const html = fn( { observer_id: dqPayload, sample_count: 5 }, false, analyticsEsc, - '-100', '-105', '-95', '', '', TAG_PAYLOAD + ATTR_PAYLOAD); + '-100', '-105', '-95', '', '', TAG_PAYLOAD + ATTR_PAYLOAD, + airCtx.window.ObserverAirtime, { duty_cycle_warn_pct: 10 }); assert.ok(!/