mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-28 00:34:07 +00:00
Partial fix for #1768 — Relay Airtime Share now uses closed-form LoRa Time-on-Air instead of a payload-bytes-only proxy, removing the ~3-4× bias against small frames (preamble + fixed-symbol intercept). cross-stack: justified — backend score formula needs a frontend caption change (`public/analytics.js` dumbbell preset banner + tooltip) so operators can interpret the assumed PHY block. Both move together or the metric is misleading. ## Red commit `8da57062` — failing test asserts ToA-based score (~83.48 % ADVERT share on the locked acceptance fixture) instead of the byte proxy's 95.24 %. `internal/lora.TimeOnAir` was a zero-returning stub at the red commit; tests failed with assertion errors, not build errors. ## Green commit `dd402edd` — implements `lora.TimeOnAir` (Semtech AN1200.13 / SX126x §6.1.4 closed form, cross-checked against RadioLib), wires `score = TimeOnAir(payloadBytes, preset) × distinctRelays` in `cmd/server/relay_airtime_share.go`, surfaces the preset in the JSON response and analytics caption. ## Config (per AGENTS Config Documentation Rule) New keys under existing `analytics` block: ```json "loraPreset": { "freq": 869600000, "bw": 62.5, "sf": 8, "cr": 5 } ``` Defaults match the deployment's actual `get radio` (869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5). `CRC=1`, `IH=0`, `DE = (T_sym ≥ 16 ms)`, and the SF-dependent preamble (32 for SF≤8 else 16, per firmware `preambleLengthForSF` / MeshCore PR #1954) are firmware-fixed constants in `internal/lora/toa.go` and intentionally NOT surfaced as config (per re-triage). ## Scope In-scope files (6): - `internal/lora/toa.go` (new package — closed-form ToA) - `internal/lora/toa_test.go` (table-driven preset tests) - `cmd/server/relay_airtime_share.go` (wire ToA into score) - `cmd/server/relay_airtime_share_test.go` (recomputed expected values) - `cmd/server/config.go` + `config.example.json` (preset config keys) - `public/analytics.js` (preset caption on dumbbell chart + tooltip) Plus `cmd/server/go.mod` (replace directive for the new internal module). ## Deferred to v2 (separate issues per re-triage) - Per-observation SF/BW + radio-settings-aware dedup (blocked: ingestor stores SNR/RSSI only, no SF/BW on observations). - CR-per-hop dual-point sensitivity band (CR scales only the payload symbol term `(CR+4)`, not the preamble/header; second-order accuracy gain). - Cross-SF bridge accounting. ## Tests ``` cd internal/lora && go test ./... → PASS cd cmd/server && go test -run RelayAirtime → PASS ``` ## Preflight overrides - `check-branch-clean` (cross-stack): justified above — score formula change requires matching caption update; both files trace to the same issue. --------- Co-authored-by: kpa-clawbot <kpa-clawbot@users.noreply.github.com> Co-authored-by: Kpa-clawbot <bot@openclaw.local> Co-authored-by: bot <bot@meshcore>
This commit is contained in:
co-authored by
kpa-clawbot
Kpa-clawbot
bot
parent
b3b8bec5ec
commit
57956712e7
@@ -23,6 +23,7 @@ COPY internal/dbschema/ ../../internal/dbschema/
|
||||
COPY internal/prunequeue/ ../../internal/prunequeue/
|
||||
COPY internal/perfio/ ../../internal/perfio/
|
||||
COPY internal/mbcapqueue/ ../../internal/mbcapqueue/
|
||||
COPY internal/lora/ ../../internal/lora/
|
||||
RUN go mod download
|
||||
COPY cmd/server/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
|
||||
@@ -929,6 +929,21 @@ func (c *Config) IsObserverBlacklisted(id string) bool {
|
||||
type AnalyticsConfig struct {
|
||||
DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
|
||||
RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
|
||||
// LoRaPreset is the assumed PHY preset used by the relay-airtime-share
|
||||
// metric to compute true Time-on-Air (issue #1768). Defaults to the
|
||||
// EU MeshCore deployment: 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5.
|
||||
// freq is informational only and surfaces in the analytics caption.
|
||||
LoRaPreset *LoRaPresetConfig `json:"loraPreset,omitempty"`
|
||||
}
|
||||
|
||||
// LoRaPresetConfig is the user-facing PHY preset for ToA scoring.
|
||||
// Only the four free params live here; CRC/IH/DE are firmware-fixed
|
||||
// in internal/lora and intentionally not surfaced as config.
|
||||
type LoRaPresetConfig struct {
|
||||
FreqHz float64 `json:"freq,omitempty"` // e.g. 869.6e6
|
||||
BWkHz float64 `json:"bw,omitempty"` // e.g. 62.5
|
||||
SF int `json:"sf,omitempty"` // e.g. 8
|
||||
CR int `json:"cr,omitempty"` // 5..8 (denominator suffix of 4/5..4/8)
|
||||
}
|
||||
|
||||
// AnalyticsDefaultRecomputeInterval returns the configured default
|
||||
|
||||
@@ -30,6 +30,10 @@ require github.com/meshcore-analyzer/dbschema v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema
|
||||
|
||||
require github.com/meshcore-analyzer/lora v0.0.0
|
||||
|
||||
replace github.com/meshcore-analyzer/lora => ../../internal/lora
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
|
||||
@@ -1,27 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/meshcore-analyzer/lora"
|
||||
)
|
||||
|
||||
// relay_airtime_share.go — issue #1359
|
||||
// relay_airtime_share.go — issues #1359 + #1768
|
||||
//
|
||||
// Implements the "Relay Airtime Share" analytics metric:
|
||||
// score(packet) = payload_bytes × COUNT(DISTINCT repeater_pubkey
|
||||
// across all observations of that packet)
|
||||
// score(packet) = TimeOnAir(payload_bytes, preset)
|
||||
// × COUNT(DISTINCT repeater_pubkey across observations)
|
||||
//
|
||||
// #1768 swapped the original byte-only proxy (`bytes × relays`) for
|
||||
// closed-form LoRa Time-on-Air. The byte proxy underweighted small
|
||||
// frames by ~3-4× because the additive preamble + fixed-symbol
|
||||
// intercept does NOT cancel under per-type normalization. ToA fixes
|
||||
// the headline divergence the dumbbell chart is supposed to show.
|
||||
//
|
||||
// The PHY preset is config-driven (analytics.loraPreset in
|
||||
// config.example.json); defaults match the actual deployment
|
||||
// preset 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5, with the
|
||||
// SF-dependent preamble pulled from internal/lora.PreambleForSF.
|
||||
//
|
||||
// Aggregated by payload_type. Originator TX is deliberately excluded — a
|
||||
// never-relayed direct message scores 0, which is the correct framing for a
|
||||
// "relay amplification" metric.
|
||||
// "relay amplification" metric. In-memory only; no SQL, no new index.
|
||||
|
||||
// defaultLoRaPreset is the canonical fallback when config is absent.
|
||||
// Matches the reporter's `get radio` output `869.6179809, 62.5, 8, 5`.
|
||||
func defaultLoRaPreset() lora.Preset {
|
||||
return lora.Preset{
|
||||
FreqHz: 869.6e6,
|
||||
BWkHz: 62.5,
|
||||
SF: 8,
|
||||
CR: 5,
|
||||
Preamble: lora.PreambleForSF(8),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLoRaPreset returns the effective preset, falling back to
|
||||
// defaults for any unset / zero / out-of-range field.
|
||||
//
|
||||
// In-memory only; no SQL, no new index, no schema change. The resolved-pubkey
|
||||
// reverse index (populated under s.mu via addToResolvedPubkeyIndex from every
|
||||
// observation's resolved_path) is the source of distinct relays per
|
||||
// transmission — len(resolvedPubkeyReverse[tx.ID]) IS the union of distinct
|
||||
// repeater pubkeys, deduplicated cross-observation. Critical: this is NOT the
|
||||
// length of any single observation's resolved_path (the bug-trap from
|
||||
// #1358's follow-up SQL hint).
|
||||
// Out-of-range SF / CR are NOT silently clamped on a per-field basis
|
||||
// (the prior behaviour produced a confusing hybrid preset, partially
|
||||
// operator-supplied and partially defaulted). Instead we keep the
|
||||
// default for the offending field AND log a single WARN at resolve time
|
||||
// naming the field plus the actual vs. effective value. There is no
|
||||
// startup-time analytics-config validation gate today, so refusal-to-
|
||||
// start is not an option — the WARN is the gate. Zero / unset fields
|
||||
// fall back silently as before (the operator opted out of overriding
|
||||
// that param).
|
||||
func (s *PacketStore) resolveLoRaPreset() lora.Preset {
|
||||
p := defaultLoRaPreset()
|
||||
if s == nil || s.config == nil || s.config.Analytics == nil || s.config.Analytics.LoRaPreset == nil {
|
||||
return p
|
||||
}
|
||||
cfg := s.config.Analytics.LoRaPreset
|
||||
if cfg.FreqHz > 0 {
|
||||
p.FreqHz = cfg.FreqHz
|
||||
}
|
||||
if cfg.BWkHz > 0 {
|
||||
p.BWkHz = cfg.BWkHz
|
||||
}
|
||||
if cfg.SF != 0 {
|
||||
if cfg.SF >= 6 && cfg.SF <= 12 {
|
||||
p.SF = cfg.SF
|
||||
p.Preamble = lora.PreambleForSF(cfg.SF)
|
||||
} else {
|
||||
log.Printf("[analytics.loraPreset] WARN: sf=%d out of range [6,12], using default sf=%d", cfg.SF, p.SF)
|
||||
}
|
||||
}
|
||||
if cfg.CR != 0 {
|
||||
if cfg.CR >= 5 && cfg.CR <= 8 {
|
||||
p.CR = cfg.CR
|
||||
} else {
|
||||
log.Printf("[analytics.loraPreset] WARN: cr=%d out of range [5,8], using default cr=%d", cfg.CR, p.CR)
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// presetResponse shapes the preset for the API response and the
|
||||
// analytics caption (issue #1768 — operators can't interpret an
|
||||
// "Airtime %" headline without knowing what PHY assumptions it bakes
|
||||
// in). All four free params plus the derived preamble are surfaced.
|
||||
type presetResponse struct {
|
||||
FreqHz float64 `json:"freq_hz"`
|
||||
BWkHz float64 `json:"bw_khz"`
|
||||
SF int `json:"sf"`
|
||||
CR int `json:"cr"`
|
||||
Preamble int `json:"preamble"`
|
||||
}
|
||||
|
||||
func presetJSON(p lora.Preset) presetResponse {
|
||||
return presetResponse{
|
||||
FreqHz: p.FreqHz,
|
||||
BWkHz: p.BWkHz,
|
||||
SF: p.SF,
|
||||
CR: p.CR,
|
||||
Preamble: p.Preamble,
|
||||
}
|
||||
}
|
||||
|
||||
// distinctRelayCount returns the number of distinct repeater pubkeys that
|
||||
// forwarded `tx`, unioned across ALL observations of that transmission_id.
|
||||
@@ -46,7 +128,7 @@ func (s *PacketStore) distinctRelayCount(tx *StoreTx) int {
|
||||
// {
|
||||
// "rows": [{payload_type, type, count, count_pct, score, airtime_pct}, ...] sorted by airtime_pct desc,
|
||||
// "total_count": int,
|
||||
// "total_score": int,
|
||||
// "total_score": int64 (nanoseconds of LoRa Time-on-Air × repeater-count, summed across packets),
|
||||
// "window": window label,
|
||||
// "cached": false (overwritten by cached wrapper),
|
||||
// }
|
||||
@@ -55,15 +137,16 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
ptNames := payloadTypeNames
|
||||
preset := s.resolveLoRaPreset()
|
||||
|
||||
type bucket struct {
|
||||
count int
|
||||
score int
|
||||
score int64 // sum of ToA(payload) × relays, in nanoseconds
|
||||
}
|
||||
buckets := make(map[int]*bucket)
|
||||
seenHash := make(map[string]bool, len(s.packets))
|
||||
totalCount := 0
|
||||
totalScore := 0
|
||||
var totalScore int64
|
||||
|
||||
for _, tx := range s.packets {
|
||||
if tx == nil || tx.PayloadType == nil {
|
||||
@@ -90,10 +173,13 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int
|
||||
b.count++
|
||||
totalCount++
|
||||
|
||||
// payload bytes from RawHex (2 hex chars per byte).
|
||||
// payload bytes from RawHex (2 hex chars per byte). Score is
|
||||
// LoRa Time-on-Air (nanoseconds) × distinct relays — see
|
||||
// resolveLoRaPreset for the assumed PHY block (issue #1768).
|
||||
payloadBytes := len(tx.RawHex) / 2
|
||||
relays := s.distinctRelayCount(tx)
|
||||
score := payloadBytes * relays
|
||||
toa := lora.TimeOnAir(payloadBytes, preset)
|
||||
score := int64(toa) * int64(relays)
|
||||
b.score += score
|
||||
totalScore += score
|
||||
}
|
||||
@@ -147,6 +233,7 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int
|
||||
"rows": rows,
|
||||
"total_count": totalCount,
|
||||
"total_score": totalScore,
|
||||
"preset": presetJSON(preset),
|
||||
"window": label,
|
||||
"cached": false,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/meshcore-analyzer/lora"
|
||||
)
|
||||
|
||||
// newRelayAirtimeShareTestStore builds a minimal PacketStore for testing
|
||||
@@ -24,10 +27,10 @@ func newRelayAirtimeShareTestStore(packets []*StoreTx) *PacketStore {
|
||||
collisionCache: make(map[string]*cachedResult),
|
||||
chanCache: make(map[string]*cachedResult),
|
||||
distCache: make(map[string]*cachedResult),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
spIndex: make(map[string]int),
|
||||
spTxIndex: make(map[string][]*StoreTx),
|
||||
advertPubkeys: make(map[string]int),
|
||||
subpathCache: make(map[string]*cachedResult),
|
||||
spIndex: make(map[string]int),
|
||||
spTxIndex: make(map[string][]*StoreTx),
|
||||
advertPubkeys: make(map[string]int),
|
||||
}
|
||||
ps.useResolvedPathIndex = true
|
||||
ps.initResolvedPathIndex()
|
||||
@@ -45,100 +48,74 @@ func newRelayAirtimeShareTestStore(packets []*StoreTx) *PacketStore {
|
||||
}
|
||||
|
||||
// makeRelayAirtimeTx builds a synthetic transmission with rawHex sized for the
|
||||
// given byte count and registers `distinctRelays` synthetic resolved-path
|
||||
// pubkeys via the resolved-pubkey reverse index — same source that
|
||||
// distinctRelayCount must read from.
|
||||
// given byte count.
|
||||
func makeRelayAirtimeTx(id int, payloadType int, payloadBytes int, distinctRelays int, hashPrefix string) *StoreTx {
|
||||
pt := payloadType
|
||||
tx := &StoreTx{
|
||||
return &StoreTx{
|
||||
ID: id,
|
||||
Hash: hashPrefix,
|
||||
FirstSeen: "2026-01-01T00:00:00Z",
|
||||
PayloadType: &pt,
|
||||
RawHex: strings.Repeat("ab", payloadBytes), // 2 hex chars per byte
|
||||
RawHex: strings.Repeat("ab", payloadBytes),
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
// TestRelayAirtimeShare_ADVERTvsACKDivergence is the locked acceptance test
|
||||
// from issue #1359:
|
||||
// - 1 ADVERT, 200 B, 8 distinct relays → score = 200 * 8 = 1600
|
||||
// - 1000 ACKs, 10 B each, 0 relays → score = 0
|
||||
// TestRelayAirtimeShare_ADVERTvsACKDivergence (issue #1768 v2 of #1359 test):
|
||||
// - 1 ADVERT, 200 B, 8 distinct relays
|
||||
// - 1000 ACKs, 10 B, 0 distinct relays
|
||||
//
|
||||
// Count distribution: ACK 1000/1001 = 99.90%, ADVERT 0.10%.
|
||||
// Airtime distribution: ADVERT 1600/1600 = 100%, ACK 0%.
|
||||
//
|
||||
// This is the headline divergence the dumbbell chart must visualize.
|
||||
// The ACK score is still 0 (no relays); ADVERT carries 100 % of airtime.
|
||||
// The headline divergence (ADVERT ranks #1 by airtime despite tiny count
|
||||
// share) survives the switch from byte-proxy to true ToA. Adds a check
|
||||
// that the JSON response surfaces the active preset (issue #1768 requires
|
||||
// the preset in the caption, so it must reach the client).
|
||||
func TestRelayAirtimeShare_ADVERTvsACKDivergence(t *testing.T) {
|
||||
packets := make([]*StoreTx, 0, 1001)
|
||||
|
||||
// 1 ADVERT with 200 bytes payload + 8 distinct relays
|
||||
advert := makeRelayAirtimeTx(1, PayloadADVERT, 200, 8, "ad000001")
|
||||
packets = append(packets, advert)
|
||||
|
||||
// 1000 ACKs with 10 bytes payload + 0 relays
|
||||
for i := 0; i < 1000; i++ {
|
||||
ack := makeRelayAirtimeTx(100+i, PayloadACK, 10, 0, "")
|
||||
// Give each a unique hash so dedup doesn't collapse them.
|
||||
ack.Hash = "ac" + zeroPad(i, 6)
|
||||
packets = append(packets, ack)
|
||||
}
|
||||
|
||||
store := newRelayAirtimeShareTestStore(packets)
|
||||
|
||||
// Wire up the 8 distinct relay pubkeys for the ADVERT through the
|
||||
// resolved-pubkey reverse index — the helper distinctRelayCount must
|
||||
// read from this source (union across all observations of tx.ID).
|
||||
relayPks := []string{
|
||||
"relay01", "relay02", "relay03", "relay04",
|
||||
"relay05", "relay06", "relay07", "relay08",
|
||||
}
|
||||
relayPks := []string{"r01", "r02", "r03", "r04", "r05", "r06", "r07", "r08"}
|
||||
store.addToResolvedPubkeyIndex(advert.ID, relayPks)
|
||||
|
||||
// Sanity check the helper directly.
|
||||
if got := store.distinctRelayCount(advert); got != 8 {
|
||||
t.Fatalf("distinctRelayCount(ADVERT) = %d, want 8", got)
|
||||
}
|
||||
if got := store.distinctRelayCount(packets[1]); got != 0 {
|
||||
t.Fatalf("distinctRelayCount(ACK) = %d, want 0", got)
|
||||
}
|
||||
|
||||
result := store.computeRelayAirtimeShare(TimeWindow{})
|
||||
rows, ok := result["rows"].([]map[string]interface{})
|
||||
|
||||
// New: preset must be in the response so the client can render the
|
||||
// caption per issue #1768 (caller cannot interpret "Airtime %"
|
||||
// without knowing the assumed SF/BW/CR). The shape is a typed
|
||||
// presetResponse struct (PR #1776 review round-1: no
|
||||
// map[string]interface{} in API surfaces).
|
||||
preset, ok := result["preset"].(presetResponse)
|
||||
if !ok {
|
||||
t.Fatalf("result['rows'] missing or wrong type: %T", result["rows"])
|
||||
t.Fatalf("result['preset'] missing or wrong type: %T", result["preset"])
|
||||
}
|
||||
if len(rows) < 2 {
|
||||
t.Fatalf("expected at least 2 rows (ADVERT, ACK), got %d: %+v", len(rows), rows)
|
||||
if preset.SF == 0 || preset.BWkHz == 0 || preset.CR == 0 || preset.Preamble == 0 || preset.FreqHz == 0 {
|
||||
t.Errorf("result['preset'] has zero fields: %+v", preset)
|
||||
}
|
||||
|
||||
// Index by payload_type name.
|
||||
rows, ok := result["rows"].([]map[string]interface{})
|
||||
if !ok || len(rows) < 2 {
|
||||
t.Fatalf("unexpected rows: %T %+v", result["rows"], result["rows"])
|
||||
}
|
||||
byType := make(map[string]map[string]interface{})
|
||||
for _, r := range rows {
|
||||
name, _ := r["payload_type"].(string)
|
||||
byType[name] = r
|
||||
}
|
||||
|
||||
advertRow, hasAdvert := byType["ADVERT"]
|
||||
ackRow, hasACK := byType["ACK"]
|
||||
if !hasAdvert {
|
||||
t.Fatalf("rows missing ADVERT bucket: %+v", rows)
|
||||
}
|
||||
if !hasACK {
|
||||
t.Fatalf("rows missing ACK bucket: %+v", rows)
|
||||
advertRow := byType["ADVERT"]
|
||||
ackRow := byType["ACK"]
|
||||
if advertRow == nil || ackRow == nil {
|
||||
t.Fatalf("missing rows: %+v", rows)
|
||||
}
|
||||
|
||||
// Count percentages: ACK should be ~99.9%, ADVERT ~0.1%.
|
||||
ackCountPct, _ := ackRow["count_pct"].(float64)
|
||||
advertCountPct, _ := advertRow["count_pct"].(float64)
|
||||
if !(ackCountPct > 99.0 && ackCountPct < 100.0) {
|
||||
t.Errorf("ACK count_pct = %.4f, want ~99.9", ackCountPct)
|
||||
}
|
||||
if !(advertCountPct < 1.0 && advertCountPct > 0.0) {
|
||||
t.Errorf("ADVERT count_pct = %.4f, want ~0.1", advertCountPct)
|
||||
}
|
||||
|
||||
// Airtime percentages: ADVERT should be 100%, ACK 0%.
|
||||
advertAirtimePct, _ := advertRow["airtime_pct"].(float64)
|
||||
ackAirtimePct, _ := ackRow["airtime_pct"].(float64)
|
||||
if advertAirtimePct < 99.5 || advertAirtimePct > 100.001 {
|
||||
@@ -148,30 +125,112 @@ func TestRelayAirtimeShare_ADVERTvsACKDivergence(t *testing.T) {
|
||||
t.Errorf("ACK airtime_pct = %.4f, want 0.0", ackAirtimePct)
|
||||
}
|
||||
|
||||
// Raw score check: ADVERT = 200 * 8 = 1600.
|
||||
advertScore, _ := advertRow["score"].(int)
|
||||
if advertScore != 1600 {
|
||||
t.Errorf("ADVERT score = %d, want 1600 (200B × 8 relays)", advertScore)
|
||||
}
|
||||
ackScore, _ := ackRow["score"].(int)
|
||||
if ackScore != 0 {
|
||||
t.Errorf("ACK score = %d, want 0 (no relays)", ackScore)
|
||||
}
|
||||
|
||||
// Count integer check.
|
||||
// Count-side assertions prove the divergence story: ACK dominates
|
||||
// by raw packet count (≈99.9%) but ADVERT dominates by airtime
|
||||
// (100%). Without these the test only proves half the chart.
|
||||
advertCount, _ := advertRow["count"].(int)
|
||||
ackCount, _ := ackRow["count"].(int)
|
||||
if advertCount != 1 {
|
||||
t.Errorf("ADVERT count = %d, want 1", advertCount)
|
||||
}
|
||||
ackCount, _ := ackRow["count"].(int)
|
||||
if ackCount != 1000 {
|
||||
t.Errorf("ACK count = %d, want 1000", ackCount)
|
||||
}
|
||||
|
||||
// The divergence: ADVERT should rank #1 by airtime even though its
|
||||
// count share is the smallest. This is the whole point of the chart.
|
||||
advertCountPct, _ := advertRow["count_pct"].(float64)
|
||||
ackCountPct, _ := ackRow["count_pct"].(float64)
|
||||
// 1 of 1001 = 0.0999 %; 1000 of 1001 = 99.9001 %.
|
||||
if math.Abs(advertCountPct-(1.0/1001.0*100.0)) > 0.001 {
|
||||
t.Errorf("ADVERT count_pct = %.4f, want %.4f", advertCountPct, 1.0/1001.0*100.0)
|
||||
}
|
||||
if math.Abs(ackCountPct-(1000.0/1001.0*100.0)) > 0.001 {
|
||||
t.Errorf("ACK count_pct = %.4f, want %.4f", ackCountPct, 1000.0/1001.0*100.0)
|
||||
}
|
||||
// The headline: ACK is ≫ ADVERT by count but 0 by airtime. That
|
||||
// inversion is the whole reason the dumbbell exists.
|
||||
if !(ackCountPct > advertCountPct && advertAirtimePct > ackAirtimePct) {
|
||||
t.Errorf("expected count/airtime inversion: ackCountPct=%.4f advertCountPct=%.4f advertAirtimePct=%.4f ackAirtimePct=%.4f",
|
||||
ackCountPct, advertCountPct, advertAirtimePct, ackAirtimePct)
|
||||
}
|
||||
if rows[0]["payload_type"] != "ADVERT" {
|
||||
t.Errorf("rows must be sorted by airtime_pct desc; rows[0] payload_type = %v, want ADVERT", rows[0]["payload_type"])
|
||||
t.Errorf("rows[0] = %v, want ADVERT (sort by airtime desc)", rows[0]["payload_type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRelayAirtimeShare_ToAReplacesByteProxy is the issue #1768 acceptance
|
||||
// gate: airtime_pct must follow true LoRa Time-on-Air, NOT bytes.
|
||||
//
|
||||
// Setup: 1 ADVERT (200 B, 1 relay) and 1 ACK (10 B, 1 relay) with the
|
||||
// default EU preset (869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5,
|
||||
// preamble 32 per firmware preambleLengthForSF).
|
||||
//
|
||||
// Old byte-proxy would give ADVERT 200/(200+10) = 95.24 %.
|
||||
// True ToA per #1768 closed form:
|
||||
//
|
||||
// T_sym = 256 / 62500 = 4.096 ms
|
||||
// ADVERT (PL=200): symbols = 36.25 + (8 + ceil((1600-32+44)/32)*5)
|
||||
// = 36.25 + (8 + 51*5) = 299.25 → 1225.728 ms
|
||||
// ACK (PL=10): symbols = 36.25 + (8 + ceil((80-32+44)/32)*5)
|
||||
// = 36.25 + (8 + 3*5) = 59.25 → 242.688 ms
|
||||
// ADVERT share = 1225.728 / (1225.728 + 242.688) = 0.83476 → 83.48 %
|
||||
//
|
||||
// 83 % vs 95 % is the whole point: small frames are no longer crushed
|
||||
// against large ones because the additive preamble + fixed-overhead
|
||||
// intercept finally enters the score. A test that still passes against
|
||||
// the byte proxy would fail to gate the regression — we explicitly
|
||||
// assert away from 95 %.
|
||||
func TestRelayAirtimeShare_ToAReplacesByteProxy(t *testing.T) {
|
||||
advert := makeRelayAirtimeTx(1, PayloadADVERT, 200, 1, "ad000001")
|
||||
ack := makeRelayAirtimeTx(2, PayloadACK, 10, 1, "ac000001")
|
||||
|
||||
store := newRelayAirtimeShareTestStore([]*StoreTx{advert, ack})
|
||||
store.addToResolvedPubkeyIndex(advert.ID, []string{"relay-A"})
|
||||
store.addToResolvedPubkeyIndex(ack.ID, []string{"relay-B"})
|
||||
|
||||
result := store.computeRelayAirtimeShare(TimeWindow{})
|
||||
rows, ok := result["rows"].([]map[string]interface{})
|
||||
if !ok || len(rows) != 2 {
|
||||
t.Fatalf("unexpected rows: %T %+v", result["rows"], result["rows"])
|
||||
}
|
||||
byType := make(map[string]map[string]interface{})
|
||||
for _, r := range rows {
|
||||
name, _ := r["payload_type"].(string)
|
||||
byType[name] = r
|
||||
}
|
||||
advertPct, _ := byType["ADVERT"]["airtime_pct"].(float64)
|
||||
ackPct, _ := byType["ACK"]["airtime_pct"].(float64)
|
||||
|
||||
// True ToA acceptance bands derived INDEPENDENTLY from the
|
||||
// implementation under test, by computing each row's ToA via
|
||||
// lora.TimeOnAir on the same preset and forming the share by hand.
|
||||
// (The prior `wantAck = 16.5246` constant was just `100 - wantAdvert`
|
||||
// — a tautology that would silently pass if shares stopped summing
|
||||
// to 100. Driving from lora.TimeOnAir means a regression there
|
||||
// surfaces here.)
|
||||
preset := defaultLoRaPreset()
|
||||
advertToA := float64(lora.TimeOnAir(200, preset))
|
||||
ackToA := float64(lora.TimeOnAir(10, preset))
|
||||
wantAdvert := advertToA / (advertToA + ackToA) * 100.0
|
||||
wantAck := ackToA / (advertToA + ackToA) * 100.0
|
||||
// Sanity guards on the independent calculation. Tolerances ±0.05 pp
|
||||
// around the AN1200.13 hand-computed values keep this honest if the
|
||||
// upstream lora package ever drifts.
|
||||
if math.Abs(wantAdvert-83.4754) > 0.05 {
|
||||
t.Fatalf("independent ADVERT ToA share drifted: got %.4f want ~83.4754", wantAdvert)
|
||||
}
|
||||
if math.Abs(wantAck-16.5246) > 0.05 {
|
||||
t.Fatalf("independent ACK ToA share drifted: got %.4f want ~16.5246", wantAck)
|
||||
}
|
||||
if math.Abs(advertPct-wantAdvert) > 0.2 {
|
||||
t.Errorf("ADVERT airtime_pct = %.4f, want %.4f (true ToA)", advertPct, wantAdvert)
|
||||
}
|
||||
if math.Abs(ackPct-wantAck) > 0.2 {
|
||||
t.Errorf("ACK airtime_pct = %.4f, want %.4f (true ToA)", ackPct, wantAck)
|
||||
}
|
||||
|
||||
// Negative gate: the OLD byte-proxy answer must NOT come back.
|
||||
// 95 % vs 5 % means we're still on the bytes×relays code path.
|
||||
if advertPct > 90.0 {
|
||||
t.Errorf("ADVERT airtime_pct = %.4f looks like byte proxy (≈95.24); ToA path missing", advertPct)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -383,6 +383,13 @@
|
||||
"roles": 300,
|
||||
"observersClockSkew": 300,
|
||||
"nodesClockSkew": 300
|
||||
},
|
||||
"loraPreset": {
|
||||
"freq": 869600000,
|
||||
"bw": 62.5,
|
||||
"sf": 8,
|
||||
"cr": 5,
|
||||
"_comment_": "Issue #1768. LoRa PHY preset assumed by the Relay Airtime Share metric to compute true Time-on-Air. Share numbers are only meaningful relative to one preset, so operators MUST set this to match their mesh — freq is informational (surfaces in the chart caption) and does not affect ToA; bw is bandwidth in kHz (e.g. 62.5, 125, 250); sf is spreading factor (6..12); cr is the denominator of the 4/N coding rate (5 ⇒ 4/5 … 8 ⇒ 4/8). Defaults reproduce the typical EU MeshCore deployment 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5. CRC=1, IH=0, DE (T_sym ≥ 16 ms), and preamble (32 for SF≤8, 16 otherwise, per firmware preambleLengthForSF) are firmware-fixed and intentionally not exposed as config."
|
||||
}
|
||||
},
|
||||
"_comment_analytics": "Issue #1240 + #1256 + #1265. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache.",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/meshcore-analyzer/lora
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package lora implements closed-form LoRa Time-on-Air calculations.
|
||||
//
|
||||
// Issue #1768 — replaces the bytes-only proxy in
|
||||
// cmd/server/relay_airtime_share.go with a true ToA estimate so the
|
||||
// "Airtime %" headline metric is no longer biased ~3-4× against small
|
||||
// frames by the preamble + fixed-symbol intercept.
|
||||
//
|
||||
// Reference: Semtech AN1200.13 / SX126x datasheet v1.1 §6.1.4.
|
||||
// Cross-checked against RadioLib calculateTimeOnAir() (issue #1768
|
||||
// discussion); MeshCore-specific constants:
|
||||
//
|
||||
// - CRC = 1 (setCRC(1) in MeshCore drivers)
|
||||
// - IH = 0 (explicit-header default, never overridden)
|
||||
// - DE = 1 iff T_sym ≥ 16 ms (per SX126x_commands.cpp:224)
|
||||
//
|
||||
// Preamble follows MeshCore's preambleLengthForSF (firmware
|
||||
// RadioLibWrappers.h:47, MeshCore PR #1954): 32 symbols for SF≤8, 16
|
||||
// otherwise. Callers should pass PreambleForSF(sf) when modeling the
|
||||
// MeshCore default; if Preset.Preamble is zero TimeOnAir falls back to
|
||||
// the LoRa-protocol default 8.
|
||||
package lora
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Preset captures the LoRa PHY parameters needed to compute ToA.
|
||||
// FreqHz is informational only (recorded for the analytics caption)
|
||||
// and does not enter the ToA formula.
|
||||
type Preset struct {
|
||||
FreqHz float64 // e.g. 869.6e6 — informational only
|
||||
BWkHz float64 // bandwidth in kHz (e.g. 62.5, 125, 250)
|
||||
SF int // spreading factor (6..12)
|
||||
CR int // coding-rate denominator suffix: 5 ⇒ 4/5 … 8 ⇒ 4/8
|
||||
Preamble int // preamble symbols; 0 ⇒ LoRa default 8
|
||||
}
|
||||
|
||||
// PreambleForSF returns MeshCore's SF-dependent preamble length:
|
||||
// 32 symbols for SF≤8, 16 otherwise. Mirrors firmware
|
||||
// preambleLengthForSF (RadioLibWrappers.h:47, PR #1954).
|
||||
func PreambleForSF(sf int) int {
|
||||
if sf <= 8 {
|
||||
return 32
|
||||
}
|
||||
return 16
|
||||
}
|
||||
|
||||
// TimeOnAir returns the LoRa time-on-air for a payload of payloadBytes
|
||||
// transmitted with the given preset.
|
||||
//
|
||||
// Closed form (Semtech AN1200.13 / SX126x §6.1.4) with MeshCore
|
||||
// constants CRC=1, IH=0:
|
||||
//
|
||||
// T_sym = 2^SF / BW_Hz
|
||||
// DE = 1 if T_sym ≥ 16 ms else 0
|
||||
// n_payload = 8 + max(ceil((8·PL − 4·SF + 28 + 16·CRC − 20·IH) /
|
||||
// (4·(SF − 2·DE))) · coding_coeff, 0)
|
||||
//
|
||||
// coding_coeff = preset.CR directly (we encode the denominator 5..8
|
||||
// so coefficient = denominator; Semtech notation uses CR ∈ 1..4 with
|
||||
// coefficient = CR+4, which is the same arithmetic).
|
||||
// n_preamble = preamble + 4.25
|
||||
// ToA = (n_preamble + n_payload) · T_sym
|
||||
//
|
||||
// Invalid presets (SF outside 6..12, BW≤0, CR outside 5..8, negative
|
||||
// payload) return 0 so callers can guard cheaply on a zero result.
|
||||
func TimeOnAir(payloadBytes int, preset Preset) time.Duration {
|
||||
if payloadBytes < 0 {
|
||||
return 0
|
||||
}
|
||||
if preset.SF < 6 || preset.SF > 12 {
|
||||
return 0
|
||||
}
|
||||
if preset.BWkHz <= 0 {
|
||||
return 0
|
||||
}
|
||||
if preset.CR < 5 || preset.CR > 8 {
|
||||
return 0
|
||||
}
|
||||
preamble := preset.Preamble
|
||||
if preamble <= 0 {
|
||||
preamble = 8
|
||||
}
|
||||
|
||||
bwHz := preset.BWkHz * 1000.0
|
||||
tSym := math.Exp2(float64(preset.SF)) / bwHz // seconds per symbol
|
||||
de := 0
|
||||
if tSym*1000.0 >= 16.0 {
|
||||
de = 1
|
||||
}
|
||||
|
||||
// CRC=1, IH=0 → constant +28 + 16*1 − 20*0 = +44.
|
||||
num := 8*payloadBytes - 4*preset.SF + 28 + 16
|
||||
den := 4 * (preset.SF - 2*de)
|
||||
if den <= 0 {
|
||||
return 0
|
||||
}
|
||||
var symbolsPayload float64
|
||||
if num <= 0 {
|
||||
// AN1200.13 §4.1.1.6: the inner ceil() argument can go negative
|
||||
// for very small payloads (the −4·SF + 28 + 16·CRC terms dominate
|
||||
// 8·PL). The reference formula clamps that ceil() to ≥ 0, so
|
||||
// symbolsPayload collapses to the fixed 8-symbol header term
|
||||
// when num ≤ 0. This is a max(., 0) clamp on the payload-symbol
|
||||
// count, NOT a separate header-floor rule.
|
||||
symbolsPayload = 8
|
||||
} else {
|
||||
ceilTerm := math.Ceil(float64(num) / float64(den))
|
||||
symbolsPayload = 8 + ceilTerm*float64(preset.CR)
|
||||
}
|
||||
|
||||
preambleSymbols := float64(preamble) + 4.25
|
||||
totalSymbols := preambleSymbols + symbolsPayload
|
||||
secs := totalSymbols * tSym
|
||||
return time.Duration(secs * float64(time.Second))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package lora
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Reference values cross-checked against the closed-form computation
|
||||
// shown in issue #1768 and against RadioLib calculateTimeOnAir():
|
||||
// freq=869.6 MHz, BW=62.5 kHz, SF=8, CR=4/5, preamble=32 (SF<=8)
|
||||
// T_sym = 256/62500 = 4.096 ms
|
||||
// preamble_symbols = 32 + 4.25 = 36.25
|
||||
//
|
||||
// PL=8: num = 64 - 32 + 28 + 16 = 76; den = 4*(8 - 0) = 32 (DE=0 since T_sym<16ms)
|
||||
// ceil(76/32) = 3 → symbols_payload = 8 + 3*5 = 23
|
||||
// total = (36.25 + 23) * 4.096 = 242.688 ms
|
||||
// PL=16: total = 283.648 ms
|
||||
// PL=32: total = 365.568 ms
|
||||
// PL=64: total = 529.408 ms
|
||||
//
|
||||
// These match the table in the issue #1768 body to the microsecond.
|
||||
func TestTimeOnAir_DefaultEUPreset(t *testing.T) {
|
||||
preset := Preset{
|
||||
FreqHz: 869.6e6,
|
||||
BWkHz: 62.5,
|
||||
SF: 8,
|
||||
CR: 5,
|
||||
Preamble: PreambleForSF(8), // 32
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
pl int
|
||||
wantMs float64
|
||||
}{
|
||||
{8, 242.688},
|
||||
{16, 283.648},
|
||||
{32, 365.568},
|
||||
{64, 529.408},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := TimeOnAir(c.pl, preset)
|
||||
gotMs := float64(got) / float64(time.Millisecond)
|
||||
if math.Abs(gotMs-c.wantMs) > 0.01 {
|
||||
t.Errorf("TimeOnAir(%d B) = %.3f ms, want %.3f ms", c.pl, gotMs, c.wantMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SF11/BW125 → T_sym = 2048/125000 = 16.384 ms ≥ 16ms → DE = 1; preamble = 16.
|
||||
// PL=8:
|
||||
// num = 8*8 - 4*11 + 28 + 16 = 64 - 44 + 44 = 64
|
||||
// den = 4*(11 - 2*1) = 36; ceil(64/36) = 2
|
||||
// symbols_payload = 8 + 2*5 = 18
|
||||
// preamble_symbols = 16 + 4.25 = 20.25
|
||||
// total = (20.25 + 18) * 16.384 = 38.25 * 16.384 ≈ 626.688 ms
|
||||
func TestTimeOnAir_DERangeSF11(t *testing.T) {
|
||||
preset := Preset{
|
||||
BWkHz: 125,
|
||||
SF: 11,
|
||||
CR: 5,
|
||||
Preamble: PreambleForSF(11),
|
||||
}
|
||||
got := TimeOnAir(8, preset)
|
||||
wantMs := 626.688
|
||||
gotMs := float64(got) / float64(time.Millisecond)
|
||||
if math.Abs(gotMs-wantMs) > 0.05 {
|
||||
t.Errorf("TimeOnAir(SF11/BW125, PL=8) = %.3f ms, want ~%.3f ms", gotMs, wantMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeOnAir_InvalidPresetReturnsZero(t *testing.T) {
|
||||
cases := []Preset{
|
||||
{BWkHz: 125, SF: 5, CR: 5}, // SF too low
|
||||
{BWkHz: 125, SF: 13, CR: 5}, // SF too high
|
||||
{BWkHz: 0, SF: 8, CR: 5}, // BW zero
|
||||
{BWkHz: 125, SF: 8, CR: 4}, // CR too low
|
||||
{BWkHz: 125, SF: 8, CR: 9}, // CR too high
|
||||
}
|
||||
for _, p := range cases {
|
||||
if got := TimeOnAir(16, p); got != 0 {
|
||||
t.Errorf("TimeOnAir(invalid preset %+v) = %v, want 0", p, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreambleForSF(t *testing.T) {
|
||||
if PreambleForSF(7) != 32 || PreambleForSF(8) != 32 {
|
||||
t.Errorf("PreambleForSF(<=8) must be 32 (firmware preambleLengthForSF)")
|
||||
}
|
||||
if PreambleForSF(9) != 16 || PreambleForSF(12) != 16 {
|
||||
t.Errorf("PreambleForSF(>8) must be 16")
|
||||
}
|
||||
}
|
||||
+34
-3
@@ -406,7 +406,7 @@
|
||||
<div class="analytics-row">
|
||||
<div class="analytics-card flex-1">
|
||||
<h3><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Relay Airtime Share</h3>
|
||||
<p class="text-muted" style="margin-top:-4px;font-size:12px">Score = payload bytes × distinct repeaters that forwarded the packet. Counts relay re-transmissions; originator TX excluded. Not comparable across meshes.</p>
|
||||
<p class="text-muted" style="margin-top:-4px;font-size:12px">Score = LoRa Time-on-Air × distinct repeaters that forwarded the packet (issue #1768). Counts relay re-transmissions; originator TX excluded. Not comparable across meshes — see preset caption below.</p>
|
||||
${renderRelayAirtimeDumbbell(d.airtimeData)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -474,9 +474,33 @@
|
||||
if (totalScore <= 0) {
|
||||
return '<div class="text-muted" style="padding:20px">No relay activity observed in this window (all packets direct).</div>';
|
||||
}
|
||||
// Issue #1768 — surface the LoRa preset baked into the ToA score. Share
|
||||
// numbers are only meaningful relative to one PHY preset; operators must
|
||||
// know what was assumed. All fields are run through esc() defensively
|
||||
// (preset.sf/cr/preamble/bw_khz/freq_hz arrive from the server JSON,
|
||||
// which the operator can configure — never inject untrusted text raw).
|
||||
// BW formatted consistent with home.js / customize-v2.js presets
|
||||
// (e.g. `62.5 kHz`, `125 kHz`) — strip trailing `.0` for integer kHz.
|
||||
var preset = data && data.preset;
|
||||
var presetCaption = '';
|
||||
if (preset && typeof preset === 'object') {
|
||||
var freqMHz = Number(preset.freq_hz || 0) / 1e6;
|
||||
var bwKhz = Number(preset.bw_khz || 0);
|
||||
var bwStr = bwKhz ? bwKhz.toFixed(1).replace(/\.0$/, '') : '0';
|
||||
presetCaption =
|
||||
'<div class="dumbbell-preset text-muted" style="font-size:11px;padding:0 4px 6px 4px">' +
|
||||
'Assumed LoRa preset: ' +
|
||||
(freqMHz ? esc(freqMHz.toFixed(3)) + ' MHz / ' : '') +
|
||||
'BW ' + esc(bwStr) + ' kHz / ' +
|
||||
'SF ' + esc(String(Number(preset.sf || 0))) + ' / ' +
|
||||
'CR 4/' + esc(String(Number(preset.cr || 0))) +
|
||||
' (preamble ' + esc(String(Number(preset.preamble || 0))) + ' sym)' +
|
||||
'</div>';
|
||||
}
|
||||
// Layout: per row → label | track 0..100% | values
|
||||
var palette = ['#ef4444','#f59e0b','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6','#64748b','#f97316','#06b6d4','#84cc16'];
|
||||
var html = '<div class="dumbbell-chart" style="display:flex;flex-direction:column;gap:8px;padding:8px 4px">';
|
||||
if (presetCaption) html += presetCaption;
|
||||
rows.forEach(function (r, i) {
|
||||
var name = r.payload_type || 'UNK';
|
||||
var cnt = Number(r.count || 0);
|
||||
@@ -487,11 +511,18 @@
|
||||
var loPct = Math.min(cpct, apct);
|
||||
var hiPct = Math.max(cpct, apct);
|
||||
// Tooltip per row — payload_type, count %, count N, airtime %, raw score, caveat.
|
||||
// Score is summed (ToA in ns) × distinct repeaters across packets of
|
||||
// this type; converted to ms for human-readable display per #1768
|
||||
// round-1 review.
|
||||
var scoreMs = score / 1e6;
|
||||
var scoreStr = scoreMs >= 1000
|
||||
? (scoreMs / 1000).toFixed(2) + ' s'
|
||||
: scoreMs.toFixed(2) + ' ms';
|
||||
var tip =
|
||||
name + '\n' +
|
||||
'Count: ' + cnt.toLocaleString() + ' (' + cpct.toFixed(2) + '%)\n' +
|
||||
'Airtime: ' + apct.toFixed(2) + '% (score ' + score.toLocaleString() + ')\n' +
|
||||
'Score = bytes × distinct repeaters. Within-mesh only.';
|
||||
'Airtime: ' + apct.toFixed(2) + '% (score ' + scoreStr + ' · airtime × repeaters)\n' +
|
||||
'Score = LoRa Time-on-Air × distinct repeaters. Within-mesh only.';
|
||||
html += '<div class="dumbbell-row" title="' + esc(tip) + '" style="display:grid;grid-template-columns:80px 1fr 180px;align-items:center;gap:10px;font-size:12px">' +
|
||||
'<div class="dumbbell-label" style="font-weight:600;color:var(--text)">' + esc(name) + '</div>' +
|
||||
'<div class="dumbbell-track" style="position:relative;height:18px;background:var(--bg-elev,rgba(127,127,127,0.12));border-radius:9px">' +
|
||||
|
||||
Reference in New Issue
Block a user