Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"Logo": "readonly",
"MAX_HOP_DIST": "readonly",
"MeshAudio": "readonly",
"ObserverAirtime": "readonly",
"MeshConfigReady": "readonly",
"NodeAdverts": "readonly",
"PAYLOAD_COLORS": "readonly",
Expand Down
4 changes: 4 additions & 0 deletions cmd/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
48 changes: 46 additions & 2 deletions cmd/server/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.)

Expand Down
2 changes: 1 addition & 1 deletion cmd/server/db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
81 changes: 81 additions & 0 deletions cmd/server/observer_airtime.go
Original file line number Diff line number Diff line change
@@ -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"`
}
124 changes: 124 additions & 0 deletions cmd/server/observer_airtime_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion cmd/server/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions cmd/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(),
})
}

Expand Down
2 changes: 2 additions & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>-<8 random hex>, so copies of this file never share an ID — do not add a fixed clientId here.",
"compression": {
"gzip": false,
Expand Down
3 changes: 2 additions & 1 deletion public/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
function statusYellow() { return cssVar('--status-yellow') || '#eab308'; }
function statusRed() { return cssVar('--status-red') || '#ef4444'; }
function accentColor() { return cssVar('--accent') || '#4a9eff'; }
function snrColor(snr) { return snr > 6 ? statusGreen() : snr > 0 ? statusYellow() : statusRed(); }

Check warning on line 154 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'snrColor' is defined but never used. Allowed unused vars must match /^_/u

// --- SVG helpers ---
function sparkSvg(data, color, w = 120, h = 32) {
Expand Down Expand Up @@ -625,7 +625,7 @@

// ===================== OVERVIEW =====================
function renderOverview(el, d) {
const rf = d.rfData, topo = d.topoData, ch = d.chanData, hs = d.hashData;

Check warning on line 628 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'hs' is assigned a value but never used. Allowed unused vars must match /^_/u
el.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
Expand Down Expand Up @@ -682,7 +682,7 @@
const totalHours = pph.length;
// Pick label interval: <=24h show every 6h, <=72h every 12h, else every 24h
const labelInterval = totalHours <= 24 ? 6 : totalHours <= 72 ? 12 : 24;
const labels = pph.map((h, i) => {

Check warning on line 685 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'i' is defined but never used. Allowed unused args must match /^_/u
const hh = h.hour.slice(11, 13); // "HH"
const hourNum = parseInt(hh, 10);
if (hourNum % labelInterval === 0) {
Expand Down Expand Up @@ -3490,7 +3490,7 @@
}

function createGraphState(data) {
const nodes = (data.nodes || []).map((n, i) => ({

Check warning on line 3493 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'i' is defined but never used. Allowed unused args must match /^_/u
...n,
x: 450 + (Math.random() - 0.5) * 400,
y: 300 + (Math.random() - 0.5) * 300,
Expand Down Expand Up @@ -4379,7 +4379,7 @@
btn.addEventListener('click', () => {
const panel = document.getElementById(btn.dataset.target);
if (!panel) return;
const open = panel.style.display === 'none' || panel.style.display === '';

Check warning on line 4382 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'open' is assigned a value but never used. Allowed unused vars must match /^_/u
// Toggle: if currently hidden ('none'), open; else close.
const currentlyHidden = panel.style.display === 'none';
panel.style.display = currentlyHidden ? 'block' : 'none';
Expand Down Expand Up @@ -4494,7 +4494,7 @@
await loadRFHealthData(el);
}

async function loadRFHealthData(el) {

Check warning on line 4497 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'el' is defined but never used. Allowed unused args must match /^_/u
const grid = document.getElementById('rfHealthGrid');
const detail = document.getElementById('rfHealthDetail');

Expand Down Expand Up @@ -4543,6 +4543,7 @@
<span>avg: ${avgNf}</span>
<span>max: ${maxNf}</span>
<span>${obs.sample_count} samples</span>
<span data-testid="rf-cell-airtime" title="Rolling TX airtime over the last hour (not the per-sample value)">TX 1h: ${ObserverAirtime.badge(obs.tx_airtime_pct_1h, summaryData.duty_cycle_warn_pct)}</span>
</div>
</div>`;
}).join('');
Expand Down Expand Up @@ -4584,7 +4585,7 @@
}
}

async function loadRFSparkline(observerId) {

Check warning on line 4588 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'loadRFSparkline' is defined but never used. Allowed unused vars must match /^_/u
const { since, until } = rfHealthTimeRangeToParams(_rfHealthState.range, _rfHealthState.customFrom, _rfHealthState.customTo);
try {
const data = await api(`/observers/${observerId}/metrics?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`);
Expand Down Expand Up @@ -4680,7 +4681,7 @@
${hasBattery ? '<div class="rf-detail-chart" id="rfDetailBatteryChart"></div>' : ''}
</div>
<div class="rf-detail-summary">
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' : ''}
</div>`;

// Close button
Expand Down Expand Up @@ -4761,7 +4762,7 @@
return svg;
}

function rfXAxisLabels(data, sx, h, pad) {

Check warning on line 4765 in public/analytics.js

View workflow job for this annotation

GitHub Actions / ✅ JavaScript Unit Tests & Lint

'pad' is defined but never used. Allowed unused args must match /^_/u
let svg = '';
const xTicks = Math.min(6, data.length);
for (let i = 0; i < xTicks; i++) {
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@
<script src="traces.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="path-inspector.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="scope-audit.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-airtime.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
Expand Down
Loading
Loading