diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e43d15620..6f043365b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -374,6 +374,11 @@ jobs: # until corescope-migrate creates it. run: sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/seed-199-inactive-observer.sql + - name: Seed adverts with known gaps for #245 E2E + # After the migration (sets transmissions.route_mask). See the file + # header for the rows and the expected interval estimates. + run: sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/seed-245-advert-intervals.sql + - name: Start Go server with fixture DB run: | fuser -k 13581/tcp 2>/dev/null || true @@ -454,6 +459,7 @@ jobs: CHROMIUM_REQUIRE=1 node test-issue-1705-subpath-contrast-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-2073-recent-adverts-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-245-advert-intervals-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1151-orphan-separators-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1486-collapse-reopens-detail-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-rebrand-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/cmd/server/advert_intervals.go b/cmd/server/advert_intervals.go new file mode 100644 index 000000000..097dd801a --- /dev/null +++ b/cmd/server/advert_intervals.go @@ -0,0 +1,373 @@ +package main + +import ( + "encoding/json" + "math" + "sort" + "time" +) + +// Estimated advert intervals per node (#245 M1): how often a node sends +// flood and zero-hop adverts, estimated from the gaps between the adverts +// CoreScope heard, for the node detail page. +// +// What the firmware does (MeshCore source; AGENTS.md: read it, don't guess): +// - flood.advert.interval: flood_advert_interval, whole hours, 0 = off, +// else 3-168 (src/helpers/CommonCLI.h:34, src/helpers/CommonCLI.cpp:486-495). +// Default 47 h on repeaters and room servers +// (examples/simple_repeater/MyMesh.cpp:904), off on sensors. +// - advert.interval: advert_interval, stored as minutes / 2, 0 = off, else +// 60-240 minutes, so even minutes (src/helpers/CommonCLI.h:33, +// src/helpers/CommonCLI.cpp:496-505). A new install starts at 2 minutes +// (examples/simple_repeater/MyMesh.cpp:903) until the first savePrefs +// turns anything below 60 off (src/helpers/CommonCLI.cpp:160-165). +// - Both timers re-arm with futureMillis(interval) when they fire, and the +// flood advert re-arms the zero-hop timer too +// (examples/simple_repeater/MyMesh.cpp:1044-1058, 1294-1306): the +// zero-hop gap across a flood advert is irregular. +// - Extra adverts: the advert / advert.zerohop CLI commands do not re-arm +// a timer (src/helpers/CommonCLI.cpp:191-198), and a boot sends a +// zero-hop advert (examples/simple_repeater/main.cpp:119). +// - The advert carries the sender's RTC time (src/Mesh.cpp:418), which +// can be years off or jump (clkreboot, src/helpers/CommonCLI.cpp:187-190). +// +// The estimate uses the adverts the Flood and Zero-hop panels list (the +// newest nodeAdvertRouteLimit per class, GetNodeAdvertRoutes), so it costs +// no extra query; mixed and unknown adverts are left out as their class is +// ambiguous. transmissions.hash is unique, so the rows are distinct adverts. +// +// The thresholds below are hardcoded; with the minimum samples they are +// candidates for the customizer (#245 "Later", AGENTS.md rule 8). + +type advertIntervalClass int + +const ( + advertIntervalFlood advertIntervalClass = iota + advertIntervalZeroHop +) + +const ( + advertConfidenceHigh = "high" + advertConfidenceMedium = "medium" + advertConfidenceLow = "low" + advertConfidenceNone = "none" +) + +// AdvertIntervalEstimate.Status: why there is an estimate or not, so the UI +// does not repeat the server's rules. +const ( + advertIntervalEstimated = "estimated" // interval_s is set + advertIntervalNoneObserved = "none_observed" // no adverts of the class + advertIntervalTooFew = "too_few" // under advertIntervalMinSamples adverts + advertIntervalIrregular = "irregular" // enough adverts, no interval fits +) + +const ( + // advertIntervalMinSamples is the fewest adverts (two gaps) estimated. + advertIntervalMinSamples = 3 + // advertIntervalTol is how far a gap may be from k x interval, as a + // fraction of the interval, and how far outside the firmware range an + // estimate may be and still snap to it. + advertIntervalTol = 0.10 + // advertIntervalMaxMultiple is the longest run of missed adverts a gap + // may stand for (k - 1). + advertIntervalMaxMultiple = 4 + // advertIntervalRaisedRun is how many of the newest gaps in a row at the + // same multiple of the interval read as a raised setting rather than + // adverts missed in a row. + advertIntervalRaisedRun = 3 + // advertClockSlackS is how far the sender clock may be ahead of + // first_seen, and the least disagreement between a sender gap and the + // first_seen gap that rejects the sender gap (else 10 % of the gap). + advertClockSlackS = 600 +) + +// AdvertIntervalEstimate is one route class of NodeAdvertIntervals. +type AdvertIntervalEstimate struct { + // IntervalS is the estimate in seconds, snapped to the nearest value + // the firmware can be set to when it is close to one; null when there + // is no estimate (confidence none). + IntervalS *int64 `json:"interval_s"` + // RawIntervalS is the median before snapping. + RawIntervalS *int64 `json:"raw_interval_s"` + Snapped bool `json:"snapped"` + // Samples is the number of adverts used (after a raised setting, those + // since the change), GapsUsed the gaps between them that fit a whole + // multiple of the interval. + Samples int `json:"samples"` + GapsUsed int `json:"gaps_used"` + Confidence string `json:"confidence"` + // Status is advertIntervalEstimated, NoneObserved, TooFew or Irregular. + Status string `json:"status"` + LastAdvert *string `json:"last_advert"` +} + +// NodeAdvertIntervals is advertIntervals on node detail (include=advertRoutes). +type NodeAdvertIntervals struct { + Window int `json:"window"` + Flood AdvertIntervalEstimate `json:"flood"` + ZeroHop AdvertIntervalEstimate `json:"zero_hop"` +} + +// advertIntervalSample is one advert: the sender's clock (0 when unknown) +// and when it was first heard. +type advertIntervalSample struct { + senderTS int64 + heard time.Time +} + +// nodeAdvertIntervals estimates both classes from the per-class lists. +func nodeAdvertIntervals(byRoute NodeAdvertsByRoute) NodeAdvertIntervals { + return NodeAdvertIntervals{ + Window: byRoute.Limit, + Flood: estimateAdvertInterval(advertIntervalSamples(byRoute.Flood), advertIntervalFlood), + ZeroHop: estimateAdvertInterval(advertIntervalSamples(byRoute.ZeroHop), advertIntervalZeroHop), + } +} + +// advertIntervalSamples reads first_seen and the decoded advert timestamp +// of each row. Rows without a parseable first_seen are skipped; a missing +// or malformed timestamp leaves senderTS 0. +func advertIntervalSamples(rows NodeAdvertRows) []advertIntervalSample { + out := make([]advertIntervalSample, 0, len(rows)) + for _, r := range rows { + fs, _ := r["first_seen"].(string) + heard, ok := parseRelayTS(fs) + if !ok { + continue + } + s := advertIntervalSample{heard: heard} + if dj, _ := r["decoded_json"].(string); dj != "" { + var d struct { + Timestamp int64 `json:"timestamp"` + } + if json.Unmarshal([]byte(dj), &d) == nil { + s.senderTS = d.Timestamp + } + } + out = append(out, s) + } + return out +} + +// advertIntervalCandidateOK is whether the class's timer can run at about +// c seconds (within the tolerance): flood 3 h or more, zero-hop the 2 min +// new-install default or 60 min or more. Other gaps are never a candidate +// interval (a burst of manual adverts, advert.zerohop every 10-30 min). +func advertIntervalCandidateOK(c float64, class advertIntervalClass) bool { + if class == advertIntervalZeroHop { + return math.Abs(c-120) <= advertIntervalTol*120 || c >= 3600*(1-advertIntervalTol) + } + return c >= 3*3600*(1-advertIntervalTol) +} + +// estimateAdvertInterval estimates one class's interval: +// 1. Gaps between consecutive adverts (by first_seen). The sender's clock +// gives the gap when both adverts have a plausible one - not ahead of +// first_seen by more than advertClockSlackS - and its gap is positive +// and agrees with first_seen's (a clock jump does not); else first_seen. +// 2. A candidate interval is a gap seen directly (within the tolerance) in +// at least two gaps and a quarter of them, and one the class's timer +// can run at (advertIntervalCandidateOK). The one explaining the most gaps as 1-4 x +// itself wins (ties: the longer, fewer missed adverts). k x interval +// is k - 1 missed adverts; shorter gaps are extra adverts, dropped. +// 3. A raised setting: a new interval of 2-4x the old one is explained by +// the old one as missed adverts in every gap. When the newest +// advertIntervalRaisedRun gaps are all the same multiple k > 1, that is +// the setting, not adverts missed in a row: only the adverts since the +// newest gap at the old interval are used, and step 2 runs on them. +// Setting an interval re-arms its timer at once +// (src/helpers/CommonCLI.cpp:491-492, 500-501). A lowered setting needs +// nothing: the new interval explains the old gaps as multiples once it +// is a candidate. +// 4. The interval is the median of gap/k over the gaps it explains, then +// snapped to the firmware's values (snapAdvertInterval). +// +// The candidate search is O(gaps^2) with gaps < nodeAdvertRouteLimit, run +// at most once per raised setting found. +func estimateAdvertInterval(samples []advertIntervalSample, class advertIntervalClass) AdvertIntervalEstimate { + est := AdvertIntervalEstimate{Samples: len(samples), Confidence: advertConfidenceNone, Status: advertIntervalNoneObserved} + if len(samples) == 0 { + return est + } + s := append([]advertIntervalSample(nil), samples...) + sort.Slice(s, func(i, j int) bool { + if !s[i].heard.Equal(s[j].heard) { + return s[i].heard.Before(s[j].heard) + } + return s[i].senderTS < s[j].senderTS + }) + last := s[len(s)-1].heard.UTC().Format(time.RFC3339) + est.LastAdvert = &last + if len(s) < advertIntervalMinSamples { + est.Status = advertIntervalTooFew + return est + } + est.Status = advertIntervalIrregular + + gaps, from := advertGaps(s) + best, start := advertIntervalCandidate(gaps, class), 0 + for best > 0 { + cut := advertIntervalRaised(gaps, best) + if cut == 0 { + break + } + start = from[cut] + gaps, from = gaps[cut:], from[cut:] + best = advertIntervalCandidate(gaps, class) + } + if best == 0 { + return est + } + est.Samples = len(s) - start + + var units []float64 + for _, g := range gaps { + if k := advertGapMultiple(g, best); k > 0 { + units = append(units, g/float64(k)) + } + } + interval := medianFloat(units) + + used, irregular := 0, 0 + for _, g := range gaps { + switch { + case advertGapMultiple(g, interval) > 0: + used++ + case g >= interval*(1-advertIntervalTol): + irregular++ + } + } + if used < 2 { + return est + } + raw := int64(math.Round(interval)) + snapped, ok := snapAdvertInterval(interval, class) + est.RawIntervalS, est.IntervalS, est.Snapped, est.GapsUsed = &raw, &snapped, ok, used + est.Status = advertIntervalEstimated + ratio := float64(used) / float64(used+irregular) + switch { + case used >= 6 && ratio >= 0.75: + est.Confidence = advertConfidenceHigh + case used >= 3 && ratio >= 0.5: + est.Confidence = advertConfidenceMedium + default: + est.Confidence = advertConfidenceLow + } + return est +} + +// advertGaps is the positive gaps in seconds between consecutive samples +// (sorted by heard), each from the sender's clock when it is plausible, and +// for each gap the index of the sample it starts at. +func advertGaps(s []advertIntervalSample) (gaps []float64, from []int) { + plausible := func(a advertIntervalSample) bool { + return a.senderTS > 0 && a.senderTS <= a.heard.Unix()+advertClockSlackS + } + gaps, from = make([]float64, 0, len(s)-1), make([]int, 0, len(s)-1) + for i := 1; i < len(s); i++ { + g := s[i].heard.Sub(s[i-1].heard).Seconds() + if plausible(s[i]) && plausible(s[i-1]) { + ds := float64(s[i].senderTS - s[i-1].senderTS) + if ds > 0 && math.Abs(ds-g) <= math.Max(advertClockSlackS, advertIntervalTol*g) { + g = ds + } + } + if g > 0 { + gaps, from = append(gaps, g), append(from, i-1) + } + } + return gaps, from +} + +// advertIntervalCandidate picks the interval candidate (step 2 of +// estimateAdvertInterval), or 0 when no gap qualifies. +func advertIntervalCandidate(gaps []float64, class advertIntervalClass) float64 { + best, bestExplained := 0.0, 0 + for _, c := range gaps { + if !advertIntervalCandidateOK(c, class) { + continue + } + direct, explained := 0, 0 + for _, g := range gaps { + k := advertGapMultiple(g, c) + if k == 1 { + direct++ + } + if k > 0 { + explained++ + } + } + if direct < 2 || direct*4 < len(gaps) { + continue + } + if explained > bestExplained || (explained == bestExplained && c > best) { + best, bestExplained = c, explained + } + } + return best +} + +// advertIntervalRaised spots a raised setting (step 3 of +// estimateAdvertInterval): the newest advertIntervalRaisedRun gaps that are +// a multiple of interval are all the same k > 1. Gaps that are no multiple +// (extra adverts, the gap at the change) are skipped. It returns the index +// of the first gap after the newest one at interval itself, where the new +// setting starts, or 0 when there is no such run. +func advertIntervalRaised(gaps []float64, interval float64) int { + run, runK := 0, 0 + for i := len(gaps) - 1; i >= 0; i-- { + k := advertGapMultiple(gaps[i], interval) + switch { + case k == 0: + case run < advertIntervalRaisedRun: + if k == 1 || (runK > 0 && k != runK) { + return 0 + } + run, runK = run+1, k + case k == 1: + return i + 1 + } + } + return 0 +} + +// advertGapMultiple is k when gap is k x interval within the tolerance +// (k = 1..advertIntervalMaxMultiple), else 0. +func advertGapMultiple(gap, interval float64) int { + k := int(math.Round(gap / interval)) + if k < 1 || k > advertIntervalMaxMultiple || math.Abs(gap-float64(k)*interval) > advertIntervalTol*interval { + return 0 + } + return k +} + +func medianFloat(v []float64) float64 { + s := append([]float64(nil), v...) + sort.Float64s(s) + n := len(s) + if n%2 == 1 { + return s[n/2] + } + return (s[n/2-1] + s[n/2]) / 2 +} + +// snapAdvertInterval rounds seconds to the nearest value the class's setting +// can take (see the file comment): flood whole hours 3-168, zero-hop even +// minutes 60-240 or the 2-minute new-install default. Estimates further +// than the tolerance outside that range are returned rounded to a second +// and not snapped. +func snapAdvertInterval(seconds float64, class advertIntervalClass) (int64, bool) { + unit, lo, hi := 3600.0, 3.0, 168.0 // flood: hours + if class == advertIntervalZeroHop { + if math.Abs(seconds-120) <= advertIntervalTol*120 { + return 120, true + } + unit, lo, hi = 120.0, 30.0, 120.0 // zero-hop: 2-minute steps, 60-240 min + } + if seconds < lo*unit*(1-advertIntervalTol) || seconds > hi*unit*(1+advertIntervalTol) { + return int64(math.Round(seconds)), false + } + steps := math.Min(hi, math.Max(lo, math.Round(seconds/unit))) + return int64(steps * unit), true +} diff --git a/cmd/server/advert_intervals_test.go b/cmd/server/advert_intervals_test.go new file mode 100644 index 000000000..46761e5c8 --- /dev/null +++ b/cmd/server/advert_intervals_test.go @@ -0,0 +1,559 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +// #245 M1: estimated flood / zero-hop advert intervals per node. The +// firmware facts the expectations rest on (MeshCore, see advert_intervals.go): +// - flood.advert.interval: whole hours, 3-168, 0 = off +// (src/helpers/CommonCLI.cpp:486-495); default 47 h on repeaters +// (examples/simple_repeater/MyMesh.cpp:904). +// - advert.interval: minutes stored as mins/2, 60-240, 0 = off +// (src/helpers/CommonCLI.cpp:496-505); 2 min on an untouched new install +// (examples/simple_repeater/MyMesh.cpp:903, turned off by the first +// savePrefs, src/helpers/CommonCLI.cpp:162-165). + +var aiBase = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + +// aiSeries builds samples at the given multiples of interval after aiBase. +// The sender clock reads heard - 2 s (send-to-first-heard delay). +func aiSeries(interval time.Duration, at ...float64) []advertIntervalSample { + out := make([]advertIntervalSample, 0, len(at)) + for _, k := range at { + heard := aiBase.Add(time.Duration(k * float64(interval))) + out = append(out, advertIntervalSample{senderTS: heard.Unix() - 2, heard: heard}) + } + return out +} + +// aiGaps builds samples from aiBase separated by the given gaps, oldest +// first, the sender clock as in aiSeries. +func aiGaps(gaps ...time.Duration) []advertIntervalSample { + heard := aiBase + out := []advertIntervalSample{{senderTS: heard.Unix() - 2, heard: heard}} + for _, g := range gaps { + heard = heard.Add(g) + out = append(out, advertIntervalSample{senderTS: heard.Unix() - 2, heard: heard}) + } + return out +} + +// aiRepeat is n copies of gap. +func aiRepeat(gap time.Duration, n int) []time.Duration { + out := make([]time.Duration, n) + for i := range out { + out[i] = gap + } + return out +} + +func aiInts(from, to int) []float64 { + var out []float64 + for i := from; i <= to; i++ { + out = append(out, float64(i)) + } + return out +} + +func aiCheck(t *testing.T, got AdvertIntervalEstimate, wantInterval int64, wantConf string, wantSamples, wantGaps int) { + t.Helper() + if wantInterval == 0 { + if got.IntervalS != nil { + t.Fatalf("interval_s = %d, want null (%+v)", *got.IntervalS, got) + } + } else if got.IntervalS == nil || *got.IntervalS != wantInterval { + t.Fatalf("interval_s = %v, want %d (%+v)", ptrStr(got.IntervalS), wantInterval, got) + } + if got.Confidence != wantConf || got.Samples != wantSamples || got.GapsUsed != wantGaps { + t.Fatalf("confidence/samples/gaps_used = %s/%d/%d, want %s/%d/%d (%+v)", + got.Confidence, got.Samples, got.GapsUsed, wantConf, wantSamples, wantGaps, got) + } +} + +func ptrStr(p *int64) string { + if p == nil { + return "null" + } + return fmt.Sprint(*p) +} + +func TestEstimateAdvertInterval_RegularSeries(t *testing.T) { + s := aiSeries(12*time.Hour, aiInts(0, 9)...) + got := estimateAdvertInterval(s, advertIntervalFlood) + aiCheck(t, got, 12*3600, advertConfidenceHigh, 10, 9) + if !got.Snapped || got.RawIntervalS == nil || *got.RawIntervalS != 12*3600 { + t.Fatalf("raw/snapped = %v/%v", ptrStr(got.RawIntervalS), got.Snapped) + } + if want := aiBase.Add(9 * 12 * time.Hour).Format(time.RFC3339); got.LastAdvert == nil || *got.LastAdvert != want { + t.Fatalf("last_advert = %v, want %s", got.LastAdvert, want) + } + // Input order does not matter: rows arrive in ingest order. + rev := make([]advertIntervalSample, len(s)) + for i := range s { + rev[len(s)-1-i] = s[i] + } + aiCheck(t, estimateAdvertInterval(rev, advertIntervalFlood), 12*3600, advertConfidenceHigh, 10, 9) +} + +// Missed adverts: a gap of 2x or 3x the interval is the interval with one or +// two adverts nobody heard, not a longer interval. Every gap fits. +func TestEstimateAdvertInterval_MissedAdverts(t *testing.T) { + // 47 h (the repeater default); adverts 3, 6 and 7 missed: gaps 47, 47, + // 94, 47, 141, 47, 47, 47 h. + s := aiSeries(47*time.Hour, 0, 1, 2, 4, 5, 8, 9, 10, 11) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 47*3600, advertConfidenceHigh, 9, 8) + + // Half the gaps doubled or tripled: the median of the raw gaps is 2x, + // only the multiple handling brings it back to 12 h. + s = aiSeries(12*time.Hour, 0, 2, 4, 5, 7, 10, 12, 13, 15) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 9, 8) +} + +// Most adverts missed (a zero-hop advert heard only now and then): the gaps +// cluster at 2x, but enough sit at the interval itself to show it. +func TestEstimateAdvertInterval_MostlyMissed(t *testing.T) { + // 60 min zero-hop; gaps 120, 120, 60, 180, 120, 60, 120 min. + s := aiSeries(time.Hour, 0, 2, 4, 5, 8, 10, 11, 13) + aiCheck(t, estimateAdvertInterval(s, advertIntervalZeroHop), 3600, advertConfidenceHigh, 8, 7) +} + +// Manual or extra adverts (the advert / advert.zerohop CLI commands do not +// re-arm the timer, src/helpers/CommonCLI.cpp:191-198; a reboot sends a +// zero-hop advert, examples/simple_repeater/main.cpp:119) split a regular gap +// into two short ones. Those gaps are dropped; the interval stays. +func TestEstimateAdvertInterval_ExtraAdverts(t *testing.T) { + // 120 min zero-hop, 12 timer adverts plus extras at 2.3 and 7.75. + s := aiSeries(2*time.Hour, append(aiInts(0, 11), 2.3, 7.75)...) + aiCheck(t, estimateAdvertInterval(s, advertIntervalZeroHop), 7200, advertConfidenceHigh, 14, 9) + + // A burst of manual adverts (minutes apart, below the 3 h flood minimum, + // so never a timer interval) must not drag the interval down: 6 timer + // adverts at 12 h and 6 manual ones 7 min apart after one of them - more + // "regular" gaps than the timer's own. + s = aiSeries(12*time.Hour, 0, 1, 2, 3, 4, 5, 3.01, 3.02, 3.03, 3.04, 3.05, 3.06) + got := estimateAdvertInterval(s, advertIntervalFlood) + aiCheck(t, got, 12*3600, advertConfidenceMedium, 12, 5) +} + +// An outage of many intervals (node off, observers down) is one long gap. +// The median ignores it; a mean would not. +func TestEstimateAdvertInterval_LongOutage(t *testing.T) { + s := aiSeries(12*time.Hour, 0, 1, 2, 3, 4, 5, 6, 7, 8, 30) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 10, 8) +} + +// A gap of up to 4x the interval is missed adverts (three in a row); a +// longer one is an outage and irregular. +func TestEstimateAdvertInterval_MultipleLimit(t *testing.T) { + // 12 h with one 4x gap: all 8 gaps fit. + s := aiSeries(12*time.Hour, 0, 1, 2, 3, 7, 8, 9, 10, 11) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 9, 8) + // A 5x gap instead: 7 of the 8 gaps fit. + s = aiSeries(12*time.Hour, 0, 1, 2, 3, 8, 9, 10, 11, 12) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 9, 7) +} + +// A raised setting: the new interval is 2-4x the old one, so the old one +// explains every new gap as missed adverts. A run of gaps at the same +// multiple is the setting, not adverts missed in a row: once the newest 3 +// are, the estimate uses the adverts since the newest gap at the old +// interval. Setting either interval re-arms its timer at once +// (src/helpers/CommonCLI.cpp:491-492, 500-501), so the gap at the change is +// between the new interval and the new plus the old one. +func TestEstimateAdvertInterval_RaisedInterval(t *testing.T) { + H, M := time.Hour, time.Minute + series := func(parts ...[]time.Duration) []advertIntervalSample { + var gaps []time.Duration + for _, p := range parts { + gaps = append(gaps, p...) + } + return aiGaps(gaps...) + } + cases := []struct { + name string + class advertIntervalClass + s []advertIntervalSample + interval int64 + conf string + samples int + gaps int + }{ + // 10 adverts 12 h apart, then 10 more 24 h apart. + {"flood 12 h -> 24 h", advertIntervalFlood, series(aiRepeat(12*H, 9), aiRepeat(24*H, 10)), 24 * 3600, advertConfidenceHigh, 11, 10}, + {"flood 12 h -> 47 h", advertIntervalFlood, series(aiRepeat(12*H, 9), aiRepeat(47*H, 10)), 47 * 3600, advertConfidenceHigh, 11, 10}, + {"flood 24 h -> 47 h", advertIntervalFlood, series(aiRepeat(24*H, 9), aiRepeat(47*H, 10)), 47 * 3600, advertConfidenceHigh, 11, 10}, + {"zero-hop 60 -> 120 min", advertIntervalZeroHop, series(aiRepeat(60*M, 9), aiRepeat(120*M, 10)), 120 * 60, advertConfidenceHigh, 11, 10}, + {"zero-hop 120 -> 240 min", advertIntervalZeroHop, series(aiRepeat(120*M, 9), aiRepeat(240*M, 10)), 240 * 60, advertConfidenceHigh, 11, 10}, + // The timer re-armed when set: the gap at the change is 24 h + 6 h, + // irregular for both intervals. + {"flood 12 h -> 24 h, re-armed when set", advertIntervalFlood, series(aiRepeat(12*H, 9), []time.Duration{30 * H}, aiRepeat(24*H, 9)), 24 * 3600, advertConfidenceHigh, 11, 9}, + // A missed advert (48 h) and a manual one (10 h + 14 h) after the + // change: the newest 3 gaps that fit 12 h are still all 2x it. + {"flood 12 h -> 24 h, missed and manual adverts", advertIntervalFlood, series(aiRepeat(12*H, 9), []time.Duration{24 * H, 24 * H, 48 * H, 24 * H, 10 * H, 14 * H, 24 * H, 24 * H}), 24 * 3600, advertConfidenceHigh, 9, 6}, + // Three in a row at 2x: estimated from those three. + {"flood 12 h -> 24 h, 3 new gaps", advertIntervalFlood, series(aiRepeat(12*H, 16), aiRepeat(24*H, 3)), 24 * 3600, advertConfidenceMedium, 4, 3}, + // Two in a row are as likely two missed adverts: the interval stays. + {"flood 12 h, last 2 gaps 2x", advertIntervalFlood, series(aiRepeat(12*H, 17), aiRepeat(24*H, 2)), 12 * 3600, advertConfidenceHigh, 20, 19}, + // Different multiples in a row are missed adverts, not a setting. + {"flood 12 h, last 3 gaps 2x 3x 2x", advertIntervalFlood, series(aiRepeat(12*H, 10), []time.Duration{24 * H, 36 * H, 24 * H}), 12 * 3600, advertConfidenceHigh, 14, 13}, + // Lowered (47 h -> 12 h): 12 h explains the old 47 h gaps as 4x + // once it is a candidate, as before. + {"flood 47 h -> 12 h", advertIntervalFlood, series(aiRepeat(47*H, 14), aiRepeat(12*H, 5)), 12 * 3600, advertConfidenceHigh, 20, 19}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + aiCheck(t, estimateAdvertInterval(c.s, c.class), c.interval, c.conf, c.samples, c.gaps) + }) + } +} + +// Candidates are intervals the class's timer can run at. The zero-hop +// timer runs at 2 min (an untouched new install) or 60-240 min, nothing +// between (src/helpers/CommonCLI.cpp:160-165, 496-505), so a +// zero-hop candidate is 2 min or at least 54 min (60 less the tolerance). +// Manual advert.zerohop every 10-30 min is irregular, not "12 min". +func TestEstimateAdvertInterval_TimerCandidates(t *testing.T) { + M := time.Minute + manual := aiGaps(12*M, 12*M, 24*M, 36*M, 12*M, 30*M) + aiCheck(t, estimateAdvertInterval(manual, advertIntervalZeroHop), 0, advertConfidenceNone, 7, 0) + aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(50*M, 8)...), advertIntervalZeroHop), 0, advertConfidenceNone, 9, 0) + aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(2*M, 19)...), advertIntervalZeroHop), 120, advertConfidenceHigh, 20, 19) + aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(55*M, 8)...), advertIntervalZeroHop), 3600, advertConfidenceHigh, 9, 8) + // Flood stays at 3 h or more: hourly manual flood adverts are irregular. + aiCheck(t, estimateAdvertInterval(aiGaps(aiRepeat(60*M, 8)...), advertIntervalFlood), 0, advertConfidenceNone, 9, 0) +} + +// Known limitation (review F2 on #247): when coverage is so sparse that the +// interval itself is never heard twice in a row, it is no candidate and a +// multiple of it is estimated. A 47 h flood heard only 2x and 3x apart +// reads as 94 h or 141 h, with the other multiple's gaps irregular. +// Treating gaps that share a divisor as its multiples would instead read a +// 24 h series with a few 36 h gaps as 12 h; this pins the current reading. +func TestEstimateAdvertInterval_SparseCoverage(t *testing.T) { + H := time.Hour + s := aiGaps(94*H, 141*H, 94*H, 141*H, 94*H, 141*H, 94*H) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 94*3600, advertConfidenceMedium, 8, 4) + s = aiGaps(94*H, 141*H, 47*H, 94*H, 141*H, 94*H, 141*H) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 141*3600, advertConfidenceMedium, 8, 3) +} + +// A candidate must be seen directly in a quarter of the gaps, not only +// twice. Two manual adverts that each split a 12 h gap into 4 h + 8 h make +// 4 h a gap seen twice that explains every gap (4, 8 and 12 h are 1-3x +// 4 h), but 2 of 10 gaps is under a quarter, so 12 h stays. +func TestEstimateAdvertInterval_CandidateQuarter(t *testing.T) { + s := aiSeries(12*time.Hour, 0, 1, 4.0/3, 2, 3, 4, 13.0/3, 5, 6, 7, 8) + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 11, 6) +} + +// Sender clock: preferred when plausible, first_seen otherwise. +func TestEstimateAdvertInterval_SenderClock(t *testing.T) { + // first_seen jitters by up to 20 min (late uploads); the sender's + // clock is exact. The raw median is then exactly the sender's interval. + jitter := []time.Duration{0, 20 * time.Minute, -15 * time.Minute, 5 * time.Minute, 18 * time.Minute, -20 * time.Minute, 0, 10 * time.Minute} + steady := func(offset time.Duration) []advertIntervalSample { + var s []advertIntervalSample + for i, j := range jitter { + sent := aiBase.Add(time.Duration(i) * 12 * time.Hour) + s = append(s, advertIntervalSample{senderTS: sent.Add(offset).Unix(), heard: sent.Add(j)}) + } + return s + } + for _, offset := range []time.Duration{0, -60 * 24 * time.Hour, -2 * 365 * 24 * time.Hour} { + got := estimateAdvertInterval(steady(offset), advertIntervalFlood) + if got.RawIntervalS == nil || *got.RawIntervalS != 12*3600 { + t.Fatalf("offset %v: raw_interval_s = %v, want exactly 43200 from the sender clock", offset, ptrStr(got.RawIntervalS)) + } + } + + // A clock ahead of when the advert was heard is clearly wrong: the + // sender's gaps (exactly 12 h) are not used, first_seen's (12 h 1 min) are. + var ahead []advertIntervalSample + for i := 0; i < 8; i++ { + heard := aiBase.Add(time.Duration(i) * (12*time.Hour + time.Minute)) + ahead = append(ahead, advertIntervalSample{senderTS: aiBase.Add(24*time.Hour + time.Duration(i)*12*time.Hour).Unix(), heard: heard}) + } + got := estimateAdvertInterval(ahead, advertIntervalFlood) + if got.RawIntervalS == nil || *got.RawIntervalS != 12*3600+60 { + t.Fatalf("clock ahead: raw_interval_s = %v, want 43260 from first_seen", ptrStr(got.RawIntervalS)) + } + aiCheck(t, got, 12*3600, advertConfidenceHigh, 8, 7) + + // clkreboot (src/helpers/CommonCLI.cpp:187-190) sets the clock back to + // 15 May 2024 mid-series: one gap is negative on the sender's clock and + // falls back to first_seen, the rest still use the sender's clock. + s := aiSeries(12*time.Hour, aiInts(0, 9)...) + for i := 5; i < len(s); i++ { + s[i].senderTS = 1715770351 + int64(i-5)*12*3600 + } + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 10, 9) + + // A forward jump (clock sync after running behind): the sender's gap is + // days longer than first_seen's, so first_seen wins for that gap. + s = aiSeries(12*time.Hour, aiInts(0, 9)...) + for i := 4; i < len(s); i++ { + s[i].senderTS += 9 * 24 * 3600 + } + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceHigh, 10, 9) + + // No sender timestamp at all (decoded_json without one): first_seen. + s = aiSeries(12*time.Hour, aiInts(0, 5)...) + for i := range s { + s[i].senderTS = 0 + } + aiCheck(t, estimateAdvertInterval(s, advertIntervalFlood), 12*3600, advertConfidenceMedium, 6, 5) +} + +func TestEstimateAdvertInterval_TooFewSamples(t *testing.T) { + for n := 0; n <= 2; n++ { + got := estimateAdvertInterval(aiSeries(12*time.Hour, aiInts(0, n-1)...), advertIntervalFlood) + aiCheck(t, got, 0, advertConfidenceNone, n, 0) + if got.RawIntervalS != nil || got.Snapped { + t.Fatalf("n=%d: raw/snapped = %v/%v", n, ptrStr(got.RawIntervalS), got.Snapped) + } + if (n == 0) != (got.LastAdvert == nil) { + t.Fatalf("n=%d: last_advert = %v", n, got.LastAdvert) + } + } + // Three adverts are the minimum: two fitting gaps, low confidence. + aiCheck(t, estimateAdvertInterval(aiSeries(12*time.Hour, 0, 1, 2), advertIntervalFlood), 12*3600, advertConfidenceLow, 3, 2) + // Same timestamps twice (no positive gap): nothing to estimate from. + dup := aiSeries(12*time.Hour, 0, 0, 0) + aiCheck(t, estimateAdvertInterval(dup, advertIntervalFlood), 0, advertConfidenceNone, 3, 0) +} + +// status tells the UI why there is no estimate, so it does not repeat the +// minimum of 3 adverts (review F6 on #247). +func TestEstimateAdvertInterval_Status(t *testing.T) { + cases := []struct { + at []float64 + want string + }{ + {nil, advertIntervalNoneObserved}, + {[]float64{0}, advertIntervalTooFew}, + {[]float64{0, 1}, advertIntervalTooFew}, + {[]float64{0, 0, 0}, advertIntervalIrregular}, + {[]float64{0, 10, 47, 50, 121, 143}, advertIntervalIrregular}, + {[]float64{0, 1, 2}, advertIntervalEstimated}, + } + for _, c := range cases { + if got := estimateAdvertInterval(aiSeries(time.Hour*12, c.at...), advertIntervalFlood); got.Status != c.want { + t.Errorf("%v: status = %q, want %q (%+v)", c.at, got.Status, c.want, got) + } + } +} + +// Irregular adverts (a companion advertising by hand) have no interval. +func TestEstimateAdvertInterval_Irregular(t *testing.T) { + s := aiSeries(time.Hour, 0, 10, 47, 50, 121, 143) + got := estimateAdvertInterval(s, advertIntervalFlood) + if got.Confidence != advertConfidenceNone || got.IntervalS != nil || got.Samples != 6 { + t.Fatalf("irregular: %+v", got) + } +} + +// Confidence: high needs >= 6 fitting gaps and >= 75 % of the non-short +// gaps fitting, medium >= 3 and >= 50 %, low anything less. Short gaps +// (extra adverts) are dropped and count neither way. +func TestEstimateAdvertInterval_ConfidenceTiers(t *testing.T) { + cases := []struct { + at []float64 + conf string + gaps int + }{ + {aiInts(0, 6), advertConfidenceHigh, 6}, // 6 of 6 fit + {aiInts(0, 5), advertConfidenceMedium, 5}, // 5 fit: below the 6 for high + {[]float64{0, 1, 2, 3, 4, 5, 6, 7.5, 10, 13.5}, advertConfidenceMedium, 6}, // 6 fit, 3 irregular (67 %) + {[]float64{0, 1, 2, 3, 3.4, 3.6}, advertConfidenceMedium, 3}, // 3 fit, 2 short gaps dropped + {[]float64{0, 1, 2, 3, 4.5, 7, 10.5, 11.8}, advertConfidenceLow, 3}, // 3 fit, 4 irregular (43 %) + {[]float64{0, 1, 2, 2.3, 2.5, 2.7, 2.85}, advertConfidenceLow, 2}, // 2 fit + } + for i, c := range cases { + got := estimateAdvertInterval(aiSeries(12*time.Hour, c.at...), advertIntervalFlood) + if got.Confidence != c.conf || got.GapsUsed != c.gaps { + t.Errorf("case %d: confidence/gaps_used = %s/%d, want %s/%d (%+v)", i, got.Confidence, got.GapsUsed, c.conf, c.gaps, got) + } + if got.IntervalS == nil || *got.IntervalS != 12*3600 { + t.Errorf("case %d: interval_s = %v", i, ptrStr(got.IntervalS)) + } + } +} + +// The interval is the median of the fitting gaps, not their mean: one late +// first_seen (no sender clock) moves a mean, not the median. +func TestEstimateAdvertInterval_MedianNotMean(t *testing.T) { + s := aiSeries(12*time.Hour, aiInts(0, 7)...) + s[7].heard = s[7].heard.Add(time.Hour) + for i := range s { + s[i].senderTS = 0 + } + got := estimateAdvertInterval(s, advertIntervalFlood) + aiCheck(t, got, 12*3600, advertConfidenceHigh, 8, 7) + if got.RawIntervalS == nil || *got.RawIntervalS != 12*3600 { + t.Fatalf("raw_interval_s = %v, want the median 43200", ptrStr(got.RawIntervalS)) + } +} + +// Snapping follows the firmware's settable values (see the file comment). +func TestSnapAdvertInterval(t *testing.T) { + h, m := 3600.0, 60.0 + cases := []struct { + class advertIntervalClass + raw float64 + want int64 + snapped bool + }{ + {advertIntervalFlood, 47.2 * h, 47 * 3600, true}, + {advertIntervalFlood, 46.6 * h, 47 * 3600, true}, + {advertIntervalFlood, 12*h + 95, 12 * 3600, true}, + {advertIntervalFlood, 3 * h, 3 * 3600, true}, + {advertIntervalFlood, 2.8 * h, 3 * 3600, true}, // within 10 % below the 3 h minimum + {advertIntervalFlood, 2.5 * h, 9000, false}, // below 3 h: no settable value + {advertIntervalFlood, 168.4 * h, 168 * 3600, true}, + {advertIntervalFlood, 180 * h, 168 * 3600, true}, // within 10 % above the 168 h maximum + {advertIntervalFlood, 200 * h, 720000, false}, + {advertIntervalZeroHop, 120.9 * m, 120 * 60, true}, // even minutes, not whole ones + {advertIntervalZeroHop, 121.5 * m, 122 * 60, true}, + {advertIntervalZeroHop, 60.4 * m, 60 * 60, true}, + {advertIntervalZeroHop, 56 * m, 60 * 60, true}, // within 10 % below 60 min + {advertIntervalZeroHop, 50 * m, 3000, false}, // 50 min cannot be set + {advertIntervalZeroHop, 239 * m, 240 * 60, true}, + {advertIntervalZeroHop, 260 * m, 240 * 60, true}, // within 10 % above 240 min + {advertIntervalZeroHop, 270 * m, 16200, false}, + {advertIntervalZeroHop, 125, 120, true}, // the 2 min new-install default + {advertIntervalZeroHop, 10 * m, 600, false}, + {advertIntervalZeroHop, 12 * h, 43200, false}, // a flood-sized interval is not a zero-hop setting + } + for _, c := range cases { + got, snapped := snapAdvertInterval(c.raw, c.class) + if got != c.want || snapped != c.snapped { + t.Errorf("snap(%v, class %d) = %d/%v, want %d/%v", c.raw, c.class, got, snapped, c.want, c.snapped) + } + } +} + +// aiRows builds NodeAdvertRows the way GetNodeAdvertRoutes lists them +// (newest ingest first), each with decoded_json carrying the sender clock. +func aiRows(interval time.Duration, n int, newest time.Time) NodeAdvertRows { + rows := NodeAdvertRows{} + for i := 0; i < n; i++ { + heard := newest.Add(-time.Duration(i) * interval) + rows = append(rows, NodeAdvertRow{ + "first_seen": heard.Format(time.RFC3339), + "decoded_json": fmt.Sprintf(`{"type":"ADVERT","timestamp":%d}`, heard.Unix()-1), + }) + } + return rows +} + +// Each class is estimated from its own list; mixed and unknown adverts are +// left out (their class is ambiguous). No zero-hop adverts: none, with no +// last_advert. +func TestNodeAdvertIntervals_PerClass(t *testing.T) { + newest := aiBase.Add(240 * time.Hour) + byRoute := NodeAdvertsByRoute{ + Limit: nodeAdvertRouteLimit, + Flood: aiRows(12*time.Hour, 10, newest), + ZeroHop: aiRows(2*time.Hour, 8, newest.Add(-time.Hour)), + Mixed: aiRows(time.Hour, 10, newest), + Unknown: aiRows(3*time.Hour, 10, newest), + } + got := nodeAdvertIntervals(byRoute) + if got.Window != nodeAdvertRouteLimit { + t.Fatalf("window = %d", got.Window) + } + aiCheck(t, got.Flood, 12*3600, advertConfidenceHigh, 10, 9) + aiCheck(t, got.ZeroHop, 7200, advertConfidenceHigh, 8, 7) + if !got.Flood.Snapped || !got.ZeroHop.Snapped { + t.Fatalf("snapped flood/zero_hop = %v/%v: each class snaps to its own setting", got.Flood.Snapped, got.ZeroHop.Snapped) + } + if got.ZeroHop.LastAdvert == nil || *got.ZeroHop.LastAdvert != newest.Add(-time.Hour).Format(time.RFC3339) { + t.Fatalf("zero_hop last_advert = %v", got.ZeroHop.LastAdvert) + } + + byRoute.ZeroHop = NodeAdvertRows{} + got = nodeAdvertIntervals(byRoute) + aiCheck(t, got.ZeroHop, 0, advertConfidenceNone, 0, 0) + if got.ZeroHop.LastAdvert != nil { + t.Fatalf("no zero-hop adverts: last_advert = %v", *got.ZeroHop.LastAdvert) + } + aiCheck(t, got.Flood, 12*3600, advertConfidenceHigh, 10, 9) +} + +// Rows the estimator cannot use: no parseable first_seen is skipped; a +// missing or malformed sender timestamp falls back to first_seen. +func TestAdvertIntervalSamples_Parsing(t *testing.T) { + rows := NodeAdvertRows{ + {"first_seen": "2026-09-01T12:00:00Z", "decoded_json": `{"timestamp":1788264000}`}, + {"first_seen": "2026-09-01T00:00:00.000Z", "decoded_json": `{"timestamp":"x"}`}, + {"first_seen": "2026-08-31T12:00:00Z", "decoded_json": nil}, + {"first_seen": "garbage", "decoded_json": `{"timestamp":1}`}, + {"first_seen": nil}, + } + s := advertIntervalSamples(rows) + if len(s) != 3 { + t.Fatalf("samples = %d, want 3: %+v", len(s), s) + } + if s[0].senderTS != 1788264000 || s[1].senderTS != 0 || s[2].senderTS != 0 { + t.Fatalf("sender timestamps = %d/%d/%d", s[0].senderTS, s[1].senderTS, s[2].senderTS) + } + if !s[1].heard.Equal(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("heard = %v", s[1].heard) + } +} + +// API: advertIntervals rides on the include=advertRoutes opt-in, flood from +// the flood list and zero-hop from the zero-hop list. +func TestNodeDetail_AdvertIntervals(t *testing.T) { + srv, router := setupTestServer(t) + narAddRouteMask(t, srv.db) + const pk = "245aa00000000000000000000000000000000000000000000000000000000001" + if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key, name, role, last_seen, first_seen) VALUES (?, 'Interval Node', 'repeater', ?, ?)`, + pk, narAgo(time.Hour), narAgo(200*time.Hour)); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + insert := func(hash string, routeType, mask int, heard time.Time) { + if _, err := srv.db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json, from_pubkey, route_mask) + VALUES ('1100', ?, ?, ?, 4, ?, ?, ?)`, hash, heard.Format(time.RFC3339), routeType, fmt.Sprintf(`{"type":"ADVERT","timestamp":%d}`, heard.Unix()-3), pk, mask); err != nil { + t.Fatal(err) + } + } + // Flood every 12 h with one missed; zero-hop every 120 min. + for _, k := range []int{1, 2, 3, 5, 6, 7, 8} { + insert(fmt.Sprintf("f%d", k), 1, 0b0010, now.Add(-time.Duration(k)*12*time.Hour)) + } + for k := 0; k < 6; k++ { + insert(fmt.Sprintf("z%d", k), 2, 0b0100, now.Add(-time.Duration(30+120*k)*time.Minute)) + } + + req := func(q string) string { + _, raw := narGetNode(t, router, pk+q, 200) + return raw + } + if raw := req(""); strings.Contains(raw, "advertIntervals") { + t.Fatalf("advertIntervals without the opt-in: %s", raw) + } + var body struct { + Intervals *NodeAdvertIntervals `json:"advertIntervals"` + } + if err := json.Unmarshal([]byte(req(narIncludeQuery)), &body); err != nil { + t.Fatal(err) + } + if body.Intervals == nil { + t.Fatal("advertIntervals missing with include=advertRoutes") + } + aiCheck(t, body.Intervals.Flood, 12*3600, advertConfidenceHigh, 7, 6) + aiCheck(t, body.Intervals.ZeroHop, 7200, advertConfidenceMedium, 6, 5) + if body.Intervals.Flood.Status != advertIntervalEstimated || body.Intervals.ZeroHop.Status != advertIntervalEstimated { + t.Fatalf("status flood/zero_hop = %q/%q, want estimated", body.Intervals.Flood.Status, body.Intervals.ZeroHop.Status) + } + if body.Intervals.Window != nodeAdvertRouteLimit { + t.Fatalf("window = %d", body.Intervals.Window) + } +} diff --git a/cmd/server/node_advert_routes_cache.go b/cmd/server/node_advert_routes_cache.go index 1f5bd9fcf..4794a93f5 100644 --- a/cmd/server/node_advert_routes_cache.go +++ b/cmd/server/node_advert_routes_cache.go @@ -36,10 +36,11 @@ const ( ) type nodeAdvertRouteEntry struct { - byRoute NodeAdvertsByRoute - counts NodeAdvertCounts - latestID int64 // the node's newest transmission id when computed - at time.Time + byRoute NodeAdvertsByRoute + counts NodeAdvertCounts + intervals NodeAdvertIntervals // #245, derived from byRoute + latestID int64 // the node's newest transmission id when computed + at time.Time } // nodeAdvertRouteCache is usable as a zero value. Cached rows are shared @@ -114,6 +115,7 @@ func (db *DB) latestTransmissionIDForNode(pubkey string) (int64, error) { type nodeAdvertRouteResult struct { byRoute NodeAdvertsByRoute counts NodeAdvertCounts + intervals NodeAdvertIntervals floodAdvertCount7d *int } @@ -151,7 +153,7 @@ func (s *Server) nodeAdvertRoutes(pubkey string, now time.Time) (nodeAdvertRoute if err != nil { return nil, err } - fresh := nodeAdvertRouteEntry{byRoute: byRoute, counts: counts, latestID: latestID, at: now} + fresh := nodeAdvertRouteEntry{byRoute: byRoute, counts: counts, intervals: nodeAdvertIntervals(byRoute), latestID: latestID, at: now} c.put(pubkey, fresh, now) return nodeAdvertRouteScan{entry: fresh, flood7d: flood7d}, nil }) @@ -164,7 +166,7 @@ func (s *Server) nodeAdvertRoutes(pubkey string, now time.Time) (nodeAdvertRoute res.floodAdvertCount7d = &scan.flood7d } } - res.byRoute, res.counts = e.byRoute, e.counts + res.byRoute, res.counts, res.intervals = e.byRoute, e.counts, e.intervals res.counts.RouteMaskBackfill = s.db.routeMaskBackfillStatus() return res, nil } diff --git a/cmd/server/node_advert_routes_test.go b/cmd/server/node_advert_routes_test.go index 095d63d33..e83de3efa 100644 --- a/cmd/server/node_advert_routes_test.go +++ b/cmd/server/node_advert_routes_test.go @@ -472,7 +472,7 @@ func TestNodeDetail_AdvertRouteFieldsPrivacy(t *testing.T) { srv, router := narServer(t) srv.cfg.SetNodeBlacklist([]string{narNode}) _, raw := narGetNode(t, router, narNode+narIncludeQuery, 404) - if strings.Contains(raw, "advertCounts") || strings.Contains(raw, "h-flood") { + if strings.Contains(raw, "advertCounts") || strings.Contains(raw, "advertIntervals") || strings.Contains(raw, "h-flood") { t.Fatalf("blacklisted node leaked: %s", raw) } }) @@ -488,7 +488,8 @@ func TestNodeDetail_AdvertRouteFieldsPrivacy(t *testing.T) { // recentAdverts rows of a hidden identity. leaked := func(body narResponse, raw string) bool { return body.ByRoute != nil || body.Counts != nil || strings.Contains(raw, "advertCounts") || - strings.Contains(raw, "recentAdvertsByRoute") || strings.Contains(raw, "route_class") + strings.Contains(raw, "recentAdvertsByRoute") || strings.Contains(raw, "route_class") || + strings.Contains(raw, "advertIntervals") } t.Run("observer blacklist", func(t *testing.T) { srv, router := narServer(t) @@ -569,6 +570,8 @@ func TestOpenAPI_NodeAdvertRouteSchemas(t *testing.T) { check("NodeAdvertsByRoute", NodeAdvertsByRoute{}) check("NodeAdvertCounts", NodeAdvertCounts{}) check("AdvertRouteCounts", AdvertRouteCounts{}) + check("NodeAdvertIntervals", NodeAdvertIntervals{}) + check("AdvertIntervalEstimate", AdvertIntervalEstimate{}) if _, ok := props("NodeAdvert")["route_class"]; !ok { t.Error("NodeAdvert.route_class is emitted but not documented") } diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 6cd374fae..5a039d606 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -86,9 +86,9 @@ func routeDescriptions() map[string]routeMeta { "GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}}, "GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"}, "GET /api/nodes/network-status": {Summary: "Network status summary", Description: "Returns counts of active, stale, and offline nodes.", Tag: "nodes"}, - "GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema). recentAdverts is the chronological list; with include=advertRoutes, recentAdvertsByRoute and advertCounts (#2073) split the node's adverts into flood / zero_hop / mixed and the ADVERT rows of recentAdverts carry route_class. A 404 for a key with no nodes row (#199) is {error, inactive_node?, observer?}: inactive_node {public_key, name, role, last_seen, first_seen} is the inactive_nodes row when retention retired the node (no advert in retention.nodeDays; last_seen is the last advert), observer {id, name, last_seen} is the observers row when the key uploads as an observer. Both are omitted for an unknown key and for a blacklisted or hidden identity.", Tag: "nodes", Response: schemaRef("NodeDetailResponse"), + "GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema). recentAdverts is the chronological list; with include=advertRoutes, recentAdvertsByRoute and advertCounts (#2073) split the node's adverts into flood / zero_hop / mixed, advertIntervals (#245) estimates the flood and zero-hop advert intervals, and the ADVERT rows of recentAdverts carry route_class. A 404 for a key with no nodes row (#199) is {error, inactive_node?, observer?}: inactive_node {public_key, name, role, last_seen, first_seen} is the inactive_nodes row when retention retired the node (no advert in retention.nodeDays; last_seen is the last advert), observer {id, name, last_seen} is the observers row when the key uploads as an observer. Both are omitted for an unknown key and for a blacklisted or hidden identity.", Tag: "nodes", Response: schemaRef("NodeDetailResponse"), QueryParams: []paramMeta{ - {Name: "include", Description: "Opt-in extras, comma-separated (the parameter may also repeat). advertRoutes (#2073): adds recentAdvertsByRoute, advertCounts and route_class on the recentAdverts ADVERT rows. That costs a scan of all the node's ADVERT rows (cached per node for up to 30 s), so only the node page asks for it; without it the response has neither field and no route_class. Unknown values are ignored. Hidden identities never get the extras.", Type: "string"}, + {Name: "include", Description: "Opt-in extras, comma-separated (the parameter may also repeat). advertRoutes (#2073): adds recentAdvertsByRoute, advertCounts, advertIntervals (#245) and route_class on the recentAdverts ADVERT rows. That costs a scan of all the node's ADVERT rows (cached per node for up to 30 s), so only the node page asks for it; without it the response has neither field and no route_class. Unknown values are ignored. Hidden identities never get the extras.", Type: "string"}, }}, "GET /api/nodes/{pubkey}/clock-skew": {Summary: "Get node clock skew", Description: "Per-node clock-skew analysis derived from ADVERT advert-timestamps vs observation times, calibrated per observer (see ClockSkewEngine). samples is the full per-advert time series in chronological order (sparkline data) by default. Fase 5.2b's sample_limit trims that array before it's sent to the client — it only reduces JSON serialization/payload/client-decoding cost, not the server-side computation or allocation that already produced the full samples slice.", Tag: "nodes", QueryParams: []paramMeta{ @@ -259,6 +259,29 @@ func nodeAdvertRouteSchemas() map[string]*openAPISchema { Type: "object", Properties: map[string]*openAPISchema{"flood": count, "zero_hop": count, "mixed": count, "unknown": count}, }, + "NodeAdvertIntervals": { + Type: "object", + Description: "Node detail with include=advertRoutes only (#245): the node's estimated flood and zero-hop advert intervals, from the gaps between the adverts listed in recentAdvertsByRoute.flood / .zero_hop (mixed and unknown adverts are not used). A gap uses the adverts' own (sender) timestamps when both are plausible - not ahead of first_seen by more than 10 min, positive, and within max(10 min, 10 %) of the first_seen gap - else first_seen. The interval must be seen directly in at least two gaps and a quarter of them, and be one the class's timer can run at (flood 3 h or more, zero-hop 2 min or 60 min or more, each less 10 %); gaps of 2-4x it count as missed adverts, shorter gaps (manual adverts, reboots) are dropped, longer gaps that are no multiple are irregular. When the newest 3 gaps that fit are all the same multiple k > 1 the interval was raised, and the estimate is redone on the adverts since the change. It is the median of the fitting gaps, snapped to the firmware's settable values: flood.advert.interval whole hours 3-168, advert.interval even minutes 60-240 or the 2-minute new-install default. No zero-hop adverts can mean the node's zero-hop interval is 0 (off) or that no observer hears it directly. Absent without include=advertRoutes and when the node's identity is hidden; cached with recentAdvertsByRoute.", + Properties: map[string]*openAPISchema{ + "window": {Type: "integer", Description: "Most adverts per class considered (the recentAdvertsByRoute limit, 20)."}, + "flood": openAPIRef("AdvertIntervalEstimate"), + "zero_hop": openAPIRef("AdvertIntervalEstimate"), + }, + }, + "AdvertIntervalEstimate": { + Type: "object", + Description: "One route class of NodeAdvertIntervals.", + Properties: map[string]*openAPISchema{ + "interval_s": {Type: "integer", Nullable: true, Description: "Estimated interval in seconds, snapped when snapped is true; null when confidence is none."}, + "raw_interval_s": {Type: "integer", Nullable: true, Description: "The median before snapping; null when confidence is none."}, + "snapped": {Type: "boolean", Description: "true when the estimate is within 10 % of the firmware's settable range and interval_s is the nearest settable value."}, + "samples": {Type: "integer", Minimum: &zero, Description: "Adverts used; after a raised interval, the adverts since the change."}, + "gaps_used": {Type: "integer", Minimum: &zero, Description: "Gaps between the samples that fit 1-4x the interval."}, + "confidence": {Type: "string", Enum: []string{advertConfidenceHigh, advertConfidenceMedium, advertConfidenceLow, advertConfidenceNone}, Description: "high: >= 6 fitting gaps and >= 75 % of the non-short gaps fit; medium: >= 3 and >= 50 %; low: fewer; none: under 3 adverts or no interval seen at least twice."}, + "status": {Type: "string", Enum: []string{advertIntervalEstimated, advertIntervalNoneObserved, advertIntervalTooFew, advertIntervalIrregular}, Description: "estimated: interval_s is set; none_observed: no adverts of the class; too_few: under 3 adverts; irregular: enough adverts but no interval fits."}, + "last_advert": {Type: "string", Nullable: true, Description: "RFC3339 first_seen of the newest advert in the class; null when there is none."}, + }, + }, "RouteMaskBackfillStatus": { Type: "object", Description: "The ingestor's transmissions.route_mask backfill (#89). Until complete, rows without a mask are classified by their first-inserted route_type, so route classes are provisional.", @@ -356,6 +379,7 @@ func componentSchemas() map[string]interface{} { "recentAdverts": map[string]interface{}{"type": "array", "items": schemaRef("NodeAdvert"), "description": "Up to 20 most recent transmissions from this node (newest ingest first, #1345), all route classes together."}, "recentAdvertsByRoute": openAPIRef("NodeAdvertsByRoute"), "advertCounts": openAPIRef("NodeAdvertCounts"), + "advertIntervals": openAPIRef("NodeAdvertIntervals"), }, }, "NodeAdvert": map[string]interface{}{ diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 7228a9430..c0deec580 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2023,6 +2023,7 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) { if res, err := s.nodeAdvertRoutes(pubkey, time.Now()); err == nil { resp.RecentAdvertsByRoute = &res.byRoute resp.AdvertCounts = &res.counts + resp.AdvertIntervals = &res.intervals floodFromScan = res.floodAdvertCount7d } else { log.Printf("WARN nodeAdvertRoutes(%s): %v", pubkey, err) diff --git a/cmd/server/types.go b/cmd/server/types.go index 2805a87af..89fe0b075 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -860,6 +860,8 @@ type NodeDetailResponse struct { // #2073: omitted when the identity is hidden (identityHidden, #68). RecentAdvertsByRoute *NodeAdvertsByRoute `json:"recentAdvertsByRoute,omitempty"` AdvertCounts *NodeAdvertCounts `json:"advertCounts,omitempty"` + // #245: estimated flood / zero-hop advert intervals, same opt-in. + AdvertIntervals *NodeAdvertIntervals `json:"advertIntervals,omitempty"` } // NodeAdvertRow is one transmission row on node detail: the /api/packets diff --git a/docs/api-spec.md b/docs/api-spec.md index 739620d36..43707e98e 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -467,10 +467,10 @@ Node detail page data. | Param | Type | Description | |-----------|--------|-------------| -| `include` | string | Opt-in extras, comma-separated (may also repeat). `advertRoutes` adds `recentAdvertsByRoute`, `advertCounts` and `route_class` on the `recentAdverts` ADVERT rows (see [Advert route classes](#advert-route-classes)). Unknown values are ignored. | +| `include` | string | Opt-in extras, comma-separated (may also repeat). `advertRoutes` adds `recentAdvertsByRoute`, `advertCounts`, `advertIntervals` and `route_class` on the `recentAdverts` ADVERT rows (see [Advert route classes](#advert-route-classes) and [Estimated advert intervals](#estimated-advert-intervals)). Unknown values are ignored. | Without `include=advertRoutes` the response is exactly the pre-#2073 one: -no `recentAdvertsByRoute`, no `advertCounts`, no `route_class`. The breakdown +no `recentAdvertsByRoute`, no `advertCounts`, no `advertIntervals`, no `route_class`. The breakdown scans all of the node's ADVERT rows, so only the node page (full view and side panel) asks for it; the packets, live, channels and route views and the claimed-nodes lookups do not. @@ -508,10 +508,30 @@ claimed-nodes lookups do not. "7d": { "flood": number, "zero_hop": number, "mixed": number, "unknown": number }, "truncated": boolean, // more adverts than the 50,000-row cap in the 7d floor "route_mask_backfill": { "status": "pending" | "backfilling" | "complete", "remaining": number | null } + }, + "advertIntervals": { // include=advertRoutes only; absent when the identity is hidden + "window": 20, // most adverts per class considered + "flood": AdvertIntervalEstimate, + "zero_hop": AdvertIntervalEstimate } } ``` +Where `AdvertIntervalEstimate` is: + +```jsonc +{ + "interval_s": number | null, // estimate in seconds, snapped when "snapped"; null when confidence is none + "raw_interval_s": number | null, // the median before snapping + "snapped": boolean, + "samples": number, // adverts used (after a raised interval: those since the change) + "gaps_used": number, // gaps between them that fit 1-4x the interval + "confidence": "high" | "medium" | "low" | "none", + "status": "estimated" | "none_observed" | "too_few" | "irregular", + "last_advert": string (ISO) | null // first_seen of the newest advert in the class +} +``` + Where `Packet` is a transmission object (see [Packet Object](#packet-object)). #### Advert route classes @@ -557,6 +577,68 @@ whether that fallback is still in use (anything but `complete`: provisional). the node for the breakdown itself takes the identical number from that scan). +#### Estimated advert intervals + +`advertIntervals` (#245) estimates how often the node sends flood and +zero-hop adverts, from the gaps between the adverts listed in +`recentAdvertsByRoute.flood` and `.zero_hop` (so at most 20 per class, and no +extra query). Mixed and unknown adverts are not used. + +The firmware settings it maps to (MeshCore `src/helpers/CommonCLI.cpp`): + +| Class | Setting | Allowed values | Default | +|-------|---------|----------------|---------| +| `flood` | `flood.advert.interval` (hours) | 0 = off, 3–168 | 47 h on repeaters and room servers, off on sensors | +| `zero_hop` | `advert.interval` (minutes, stored / 2) | 0 = off, 60–240, even minutes | 2 min on an untouched new install, off after the first saved setting | + +- **Gaps.** A gap is taken from the adverts' own (sender) timestamps + when both are plausible: not ahead of `first_seen` by more than 10 min, + positive, and within max(10 min, 10 %) of the `first_seen` gap. Otherwise + it is taken from `first_seen`. A sender clock that is wrong by a steady + offset is still used; a jump or reset is not. +- **The interval.** It must be seen directly, within 10 %, in at least two + gaps and a quarter of them. It must be an interval the class's timer can + run at, within 10 %: flood 3 h or more; zero-hop the 2 min new-install + default or 60 min or more (so manual `advert.zerohop` every 10–30 min is + irregular). Of those candidates, the one that explains the most gaps as + 1–4× itself wins. + - A gap of k× the interval counts as k−1 missed adverts. + - Shorter gaps are dropped and count neither way: manual adverts and + reboots. + - Longer gaps that are no multiple are *irregular* and lower the + confidence. One is the zero-hop gap across a flood advert: the flood + advert re-arms the zero-hop timer, so that gap is between one and two + zero-hop intervals. +- **A raised interval.** A new interval of 2–4× the old one fits every new + gap as missed adverts of the old one. When the newest 3 gaps that fit the + interval are all the same multiple k > 1, that is read as a raised + setting: the estimate is redone on the adverts since the newest gap at the + old interval, and `samples` counts those. Two in a row, or different + multiples, stay missed adverts. A lowered interval needs no special case: + the new one explains the old gaps as multiples once it is seen in a + quarter of the gaps. +- **The value.** It is the median of gap/k over the fitting gaps (`raw_interval_s`). + It is snapped to the nearest settable value when it lies within 10 % of + the settable range (`snapped`). +- **Confidence.** + - `high`: ≥ 6 fitting gaps, and ≥ 75 % of the gaps that are not short fit. + - `medium`: ≥ 3 fitting gaps and ≥ 50 %. + - `low`: anything less. + - `none`: fewer than 3 adverts, or no interval seen twice. `interval_s` + is then `null`. +- **Status.** `estimated` when `interval_s` is set, `none_observed` with no + adverts of the class, `too_few` under 3 adverts, `irregular` otherwise. + The UI words the row from it. +- **Known limitation: sparse coverage.** When the interval itself is never + heard twice in a row (a distant node heard every second or third time), + it is no candidate, and a multiple of it is reported: a 47 h flood heard + 94 h and 141 h apart reads as 94 h or 141 h at medium confidence. +- **No zero-hop adverts.** A zero-hop advert is only recorded when an + observer hears the node directly. "None observed" can therefore mean that + the interval is 0 (off), or that no observer is in direct range. +- **Visibility and caching.** The field is cached and hidden together with + `recentAdvertsByRoute`. + ### Response `404` ```json diff --git a/public/node-adverts.js b/public/node-adverts.js index dc0822ca7..dd4ac65b2 100644 --- a/public/node-adverts.js +++ b/public/node-adverts.js @@ -4,7 +4,8 @@ * panel. The "Recent Adverts" name and its tooltip port upstream * `Kpa-clawbot/CoreScope#2071`; the per-route tabs and counts extend upstream * `Kpa-clawbot/CoreScope#2073` over the node-detail fields - * recentAdvertsByRoute and advertCounts (see docs/api-spec.md). + * recentAdvertsByRoute and advertCounts, and the estimated advert intervals + * (#245, advertIntervals) below the counts (see docs/api-spec.md). * * render() returns an HTML string built only from escaped values; bind() * wires the tab bar through the app's initTabBar (roles, arrow keys) and @@ -109,6 +110,48 @@ return html + ''; } + // #245: estimated advert intervals (advertIntervals, docs/api-spec.md). + // Flood in hours and zero-hop in minutes, the units of the firmware's + // flood.advert.interval and advert.interval settings. + var INTERVAL_TIP = 'Estimated from the gaps between the newest adverts of each class (the Flood and Zero-hop tabs), using the sender\'s own timestamps when its clock is plausible. The interval is a gap that repeats and that the firmware timer can run at; gaps of 2-4x it count as missed adverts, shorter ones (manual adverts, reboots) are ignored. When the newest gaps are all the same multiple, the interval was raised and only the adverts since then are used. Snapped to the values the firmware allows: flood.advert.interval 3-168 h; advert.interval 60-240 min in 2-minute steps, or 2 min on an untouched new install.'; + var INTERVAL_CLASSES = [ + { key: 'flood', label: 'flood', unit: 3600, unitLabel: 'h', range: '3–168 h', none: 'none observed' }, + { key: 'zero_hop', label: 'zero-hop', unit: 60, unitLabel: 'min', range: '60–240 min', none: 'none observed (off, or no observer in direct range)' } + ]; + + function fmtInterval(seconds, c) { + var v = seconds / c.unit; + if (c.unit === 3600 && v < 1) { v = seconds / 60; return Math.round(v) + ' min'; } + return (Math.round(v * 10) / 10) + ' ' + c.unitLabel; + } + + // The wording follows the server's status (estimated, none_observed, + // too_few, irregular); an unknown status is no estimate. + function intervalRow(e, c) { + e = e || {}; + var n = num(e.samples); + var seconds = Number(e.interval_s); + var label = 'Estimated ' + c.label + ' interval'; + var text; + if (e.status === 'estimated' && e.interval_s != null && seconds > 0) { + var notes = [n + ' advert' + (n === 1 ? '' : 's'), esc(e.confidence) + ' confidence']; + if (!e.snapped) notes.push('outside the settable ' + c.range); + text = ' ≈ ' + fmtInterval(seconds, c) + ' (' + notes.join(', ') + ')'; + } else { + label += ':'; + text = ' ' + (e.status === 'too_few' ? 'not enough adverts yet (' + n + ' heard)' + : e.status === 'none_observed' || !n ? c.none + : 'irregular (' + n + ' adverts, no repeating gap)'); + } + return '' + label + '' + text + ''; + } + + function intervalsHtml(intervals) { + if (!intervals) return ''; + return '
' + + INTERVAL_CLASSES.map(function (c) { return intervalRow(intervals[c.key], c); }).join('') + '
'; + } + function noteHtml(counts) { var st = counts && counts.route_mask_backfill && counts.route_mask_backfill.status; if (!counts || st === 'complete') return ''; @@ -190,7 +233,7 @@ }); var tab = tabs.some(function (t) { return t.key === opts.tab; }) ? opts.tab : 'all'; - html += countsHtml(detail.advertCounts) + noteHtml(detail.advertCounts); + html += countsHtml(detail.advertCounts) + intervalsHtml(detail.advertIntervals) + noteHtml(detail.advertCounts); // initTabBar (bind) adds role="tablist"; it skips a bar that has one. html += '
' + tabs.map(function (t) { var on = t.key === tab; diff --git a/public/nodes.js b/public/nodes.js index 516220da8..07ed7bfc0 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -882,7 +882,7 @@
- ${NodeAdverts.render({ recentAdverts: adverts, recentAdvertsByRoute: nodeData.recentAdvertsByRoute, advertCounts: nodeData.advertCounts }, { + ${NodeAdverts.render({ recentAdverts: adverts, recentAdvertsByRoute: nodeData.recentAdvertsByRoute, advertCounts: nodeData.advertCounts, advertIntervals: nodeData.advertIntervals }, { variant: 'full', idPrefix: 'nodeFullAdverts', tab: NodeAdverts.parseTab(location.hash), timestampHtml: renderNodeTimestampHtml, hashSizeInconsistent: !!n.hash_size_inconsistent, })} @@ -1925,7 +1925,7 @@
- ${NodeAdverts.render({ recentAdverts: adverts, recentAdvertsByRoute: data.recentAdvertsByRoute, advertCounts: data.advertCounts }, { + ${NodeAdverts.render({ recentAdverts: adverts, recentAdvertsByRoute: data.recentAdvertsByRoute, advertCounts: data.advertCounts, advertIntervals: data.advertIntervals }, { variant: 'pane', idPrefix: 'nodePaneAdverts', tab: NodeAdverts.parseTab(location.hash), timestampHtml: renderNodeTimestampHtml, roleColor: roleColor, })} diff --git a/public/style.css b/public/style.css index c49cf7ad5..69f055d84 100644 --- a/public/style.css +++ b/public/style.css @@ -2880,6 +2880,8 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } .node-adverts-counts { display: flex; flex-wrap: wrap; gap: 2px 16px; margin: 0 0 8px; font-size: 12px; color: var(--text-muted); } .node-adverts-count-row strong { margin-right: 4px; color: var(--text); } .node-adverts-note { margin: 0 0 8px; font-size: 12px; } +.node-adverts-intervals { display: flex; flex-direction: column; gap: 2px; margin: 0 0 8px; font-size: 12px; color: var(--text-muted); cursor: help; } +.node-adverts-interval-row strong { margin-right: 4px; color: var(--text); } .node-adverts-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 8px; } .node-adverts-tabs .tab-btn { padding: 4px 10px; } .node-adverts-empty { font-size: 13px; } diff --git a/test-fixtures/seed-245-advert-intervals.sql b/test-fixtures/seed-245-advert-intervals.sql new file mode 100644 index 000000000..76ac5941b --- /dev/null +++ b/test-fixtures/seed-245-advert-intervals.sql @@ -0,0 +1,77 @@ +-- #245 E2E seed (test-issue-245-advert-intervals-e2e.js): two repeaters with +-- known advert gaps for the estimated advert intervals on node detail. +-- Applied by CI after the fixture is migrated (route_mask must exist): +-- sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/seed-245-advert-intervals.sql +-- +-- decoded_json.timestamp is the sender's clock. Out-of-band negative ids and +-- old observation timestamps keep these rows last in the packets views (as +-- in seed-2073); first_seen is relative to now. +-- +-- Expected on /api/nodes/245e2e0...01?include=advertRoutes ("Advert Interval E2E"): +-- flood : 10 adverts at 1, 13, 25, 49, 61, 66, 73, 109, 121, 133 h ago. +-- Gaps of 12 h (x5), 24 h (one missed), 36 h (two missed), and +-- 5 h + 7 h around the manual advert at 66 h (dropped). +-- -> interval_s 43200, gaps_used 7, high. +-- zero_hop : 6 adverts every 120 min -> interval_s 7200, gaps_used 5, medium. +-- and /api/nodes/245e2e0...02 ("Flood Only Interval E2E"): +-- flood : 8 adverts 24 h apart; the sender clock jumps back to +-- 15 May 2024 after the 4th (that gap falls back to first_seen) +-- -> interval_s 86400, gaps_used 7, high. +-- zero_hop : none observed. +INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) VALUES + ('245e2e0000000000000000000000000000000000000000000000000000000001', 'Advert Interval E2E', 'repeater', + strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-30 minutes'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-6 days'), 16), + ('245e2e0000000000000000000000000000000000000000000000000000000002', 'Flood Only Interval E2E', 'repeater', + strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-2 hours'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-8 days'), 8); + +INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash, from_pubkey, route_mask) VALUES + (-245001, '1100e2e2450001', 'e2e245advert0001', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-1 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245002, '1100e2e2450002', 'e2e245advert0002', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-13 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-13 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245003, '1100e2e2450003', 'e2e245advert0003', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-25 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-25 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245004, '1100e2e2450004', 'e2e245advert0004', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-49 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-49 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245005, '1100e2e2450005', 'e2e245advert0005', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-61 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-61 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245006, '1100e2e2450006', 'e2e245advert0006', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-66 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-66 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245007, '1100e2e2450007', 'e2e245advert0007', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-73 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-73 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245008, '1100e2e2450008', 'e2e245advert0008', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-109 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-109 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245009, '1100e2e2450009', 'e2e245advert0009', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-121 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-121 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245010, '1100e2e2450010', 'e2e245advert0010', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-133 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-133 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 2), + (-245011, '1200e2e2450011', 'e2e245advert0011', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-30 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-30 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245012, '1200e2e2450012', 'e2e245advert0012', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-150 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-150 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245013, '1200e2e2450013', 'e2e245advert0013', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-270 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-270 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245014, '1200e2e2450014', 'e2e245advert0014', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-390 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-390 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245015, '1200e2e2450015', 'e2e245advert0015', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-510 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-510 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245016, '1200e2e2450016', 'e2e245advert0016', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-630 minutes'), 2, 4, 0, '{"type":"ADVERT","name":"Advert Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000001","timestamp":' || (CAST(strftime('%s', 'now', '-630 minutes') AS INTEGER) - 1) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000001', 4), + (-245017, '1100e2e2450017', 'e2e245advert0017', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-170 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (CAST(strftime('%s', 'now', '-170 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245018, '1100e2e2450018', 'e2e245advert0018', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-146 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (CAST(strftime('%s', 'now', '-146 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245019, '1100e2e2450019', 'e2e245advert0019', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-122 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (CAST(strftime('%s', 'now', '-122 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245020, '1100e2e2450020', 'e2e245advert0020', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-98 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (CAST(strftime('%s', 'now', '-98 hours') AS INTEGER) - 2) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245021, '1100e2e2450021', 'e2e245advert0021', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-74 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (1715770351 + 0) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245022, '1100e2e2450022', 'e2e245advert0022', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-50 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (1715770351 + 86400) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245023, '1100e2e2450023', 'e2e245advert0023', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-26 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (1715770351 + 172800) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2), + (-245024, '1100e2e2450024', 'e2e245advert0024', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-2 hours'), 1, 4, 0, '{"type":"ADVERT","name":"Flood Only Interval E2E","pubKey":"245e2e0000000000000000000000000000000000000000000000000000000002","timestamp":' || (1715770351 + 259200) || '}', NULL, '245e2e0000000000000000000000000000000000000000000000000000000002', 2); + +INSERT INTO observations (transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp) VALUES + (-245001, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245002, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245003, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245004, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245005, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245006, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245007, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245008, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245009, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245010, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245011, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245012, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245013, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245014, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245015, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245016, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245017, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245018, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245019, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245020, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245021, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245022, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245023, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)), + (-245024, 1, 'rx', 5.0, -95, 0, '[]', CAST(strftime('%s', '2026-05-15T00:00:00Z') AS INTEGER)); diff --git a/test-issue-2073-recent-adverts-e2e.js b/test-issue-2073-recent-adverts-e2e.js index 55223fedc..b431ffe7a 100644 --- a/test-issue-2073-recent-adverts-e2e.js +++ b/test-issue-2073-recent-adverts-e2e.js @@ -205,7 +205,7 @@ async function waitSettled(page, root) { }; }, MIX); assert(r.plainKeys === 'node,recentAdverts' && !r.plainRouteClass, 'plain: ' + JSON.stringify(r)); - assert(r.optedKeys === 'advertCounts,node,recentAdverts,recentAdvertsByRoute' && r.optedRouteClass, 'opted in: ' + JSON.stringify(r)); + assert(r.optedKeys === 'advertCounts,advertIntervals,node,recentAdverts,recentAdvertsByRoute' && r.optedRouteClass, 'opted in: ' + JSON.stringify(r)); }); await step('mobile 390×844: no horizontal overflow, tabs usable', async () => { diff --git a/test-issue-245-advert-intervals-e2e.js b/test-issue-245-advert-intervals-e2e.js new file mode 100644 index 000000000..7b5776b6e --- /dev/null +++ b/test-issue-245-advert-intervals-e2e.js @@ -0,0 +1,158 @@ +/** + * #245 M1 — estimated flood / zero-hop advert intervals on node detail + * (Recent Adverts section, full page, side panel and phone width). + * + * Needs the fixture seeded with test-fixtures/seed-245-advert-intervals.sql + * (CI applies it after migrating the fixture). Never run against prod. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-245-advert-intervals-e2e.js + * SCREENSHOT_DIR= also saves screenshots of each state. + */ +'use strict'; + +const path = require('path'); +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const SHOTS = process.env.SCREENSHOT_DIR || ''; +const BOTH = '245e2e0000000000000000000000000000000000000000000000000000000001'; +const FLOOD_ONLY = '245e2e0000000000000000000000000000000000000000000000000000000002'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } +async function shot(page, name, selector) { + if (!SHOTS) return; + await page.waitForTimeout(800); + if (selector) await page.$eval(selector, (el) => el.scrollIntoView({ block: 'start' })); + await page.screenshot({ path: path.join(SHOTS, '245-' + name + '.png'), fullPage: false }); +} + +// The interval rows inside root, by class, as the user reads them. +async function intervals(page, root) { + await page.waitForSelector(root + ' .node-adverts-intervals'); + return page.$eval(root, (el) => { + const out = {}; + el.querySelectorAll('.node-adverts-interval-row').forEach((r) => { + out[r.dataset.advertInterval] = r.textContent.replace(/\s+/g, ' ').trim(); + }); + const block = el.querySelector('.node-adverts-intervals'); + out.tip = block.getAttribute('title') || ''; + out.afterCounts = !!(el.querySelector('.node-adverts-counts') && + (el.querySelector('.node-adverts-counts').compareDocumentPosition(block) & Node.DOCUMENT_POSITION_FOLLOWING)); + return out; + }); +} + +const FLOOD_12H = 'Estimated flood interval ≈ 12 h (10 adverts, high confidence)'; +const ZH_120 = 'Estimated zero-hop interval ≈ 120 min (6 adverts, medium confidence)'; +const FLOOD_24H = 'Estimated flood interval ≈ 24 h (8 adverts, high confidence)'; +const ZH_NONE = 'Estimated zero-hop interval: none observed (off, or no observer in direct range)'; + +(async () => { + 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: 1440, height: 1000 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + const full = '#node-packets'; + + console.log(`\n=== #245 advert intervals E2E against ${BASE} ===`); + + await step('API: advertIntervals from the seeded gaps, only with include=advertRoutes', async () => { + await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' }); + const r = await page.evaluate(async (pks) => { + const get = async (u) => (await fetch(u)).json(); + return { + plain: 'advertIntervals' in (await get('/api/nodes/' + pks[0])), + both: (await get('/api/nodes/' + pks[0] + '?include=advertRoutes')).advertIntervals, + floodOnly: (await get('/api/nodes/' + pks[1] + '?include=advertRoutes')).advertIntervals, + }; + }, [BOTH, FLOOD_ONLY]); + assert(!r.plain, 'plain detail must not carry advertIntervals'); + const b = r.both, f = r.floodOnly; + assert(b && b.window === 20, 'window: ' + JSON.stringify(b)); + assert(b.flood.interval_s === 43200 && b.flood.snapped && b.flood.samples === 10 && b.flood.gaps_used === 7 && b.flood.confidence === 'high' && b.flood.status === 'estimated', + 'flood (2x and 3x gaps, one manual advert): ' + JSON.stringify(b.flood)); + assert(b.zero_hop.interval_s === 7200 && b.zero_hop.samples === 6 && b.zero_hop.gaps_used === 5 && b.zero_hop.confidence === 'medium', + 'zero-hop: ' + JSON.stringify(b.zero_hop)); + assert(f.flood.interval_s === 86400 && f.flood.gaps_used === 7 && f.flood.confidence === 'high', + 'flood with a sender clock reset: ' + JSON.stringify(f.flood)); + assert(f.zero_hop.samples === 0 && f.zero_hop.interval_s === null && f.zero_hop.confidence === 'none' && f.zero_hop.status === 'none_observed' && f.zero_hop.last_advert === null, + 'no zero-hop: ' + JSON.stringify(f.zero_hop)); + }); + + await step('full page: both classes under the counts', async () => { + await page.goto(BASE + '/#/nodes/' + BOTH, { waitUntil: 'domcontentloaded' }); + const s = await intervals(page, full); + assert(s.flood === FLOOD_12H, 'flood: ' + s.flood); + assert(s.zero_hop === ZH_120, 'zero-hop: ' + s.zero_hop); + assert(s.afterCounts, 'below the 24h / 7d counts'); + assert(/a gap that repeats/.test(s.tip) && /3-168 h/.test(s.tip) && /2 min on an untouched new install/.test(s.tip), 'tooltip explains the method: ' + s.tip); + await shot(page, 'full-both-light', full); + }); + + await step('full page: no zero-hop adverts reads as off or out of range', async () => { + await page.goto(BASE + '/#/nodes/' + FLOOD_ONLY, { waitUntil: 'domcontentloaded' }); + const s = await intervals(page, full); + assert(s.flood === FLOOD_24H, 'flood: ' + s.flood); + assert(s.zero_hop === ZH_NONE, 'zero-hop: ' + s.zero_hop); + await shot(page, 'full-flood-only-light', full); + }); + + await step('full page: dark theme', async () => { + await page.evaluate(() => localStorage.setItem('meshcore-theme', 'dark')); + await page.goto(BASE + '/#/nodes/' + BOTH, { waitUntil: 'domcontentloaded' }); + await page.reload({ waitUntil: 'domcontentloaded' }); + const s = await intervals(page, full); + assert(await page.evaluate(() => document.documentElement.getAttribute('data-theme')) === 'dark', 'dark theme active'); + assert(s.flood === FLOOD_12H, 'flood in dark: ' + s.flood); + const colors = await page.$eval(full + ' .node-adverts-intervals', (el) => { + const cs = getComputedStyle(el), strong = getComputedStyle(el.querySelector('strong')); + return { text: cs.color, label: strong.color, bg: getComputedStyle(document.body).backgroundColor }; + }); + assert(colors.text !== colors.bg && colors.label !== colors.bg, 'readable in dark: ' + JSON.stringify(colors)); + await shot(page, 'full-both-dark', full); + await page.evaluate(() => localStorage.setItem('meshcore-theme', 'light')); + }); + + await step('side panel: same text', async () => { + await page.goto(BASE + '/#/nodes?search=' + encodeURIComponent('Advert Interval E2E'), { waitUntil: 'domcontentloaded' }); + await page.click('tr[data-key="' + BOTH + '"]'); + const s = await intervals(page, '#node-pane-adverts'); + assert(s.flood === FLOOD_12H && s.zero_hop === ZH_120, 'pane: ' + JSON.stringify(s)); + await shot(page, 'pane-both-light'); + }); + + await step('mobile 390×844: no horizontal overflow', async () => { + const m = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const mp = await m.newPage(); + mp.on('pageerror', (e) => errors.push(e.message)); + await mp.goto(BASE + '/#/nodes/' + FLOOD_ONLY, { waitUntil: 'domcontentloaded' }); + const s = await intervals(mp, full); + assert(s.zero_hop === ZH_NONE, 'mobile zero-hop: ' + s.zero_hop); + const o = await mp.evaluate((sel) => { + const card = document.querySelector(sel); + return { doc: document.documentElement.scrollWidth - document.documentElement.clientWidth, card: card.scrollWidth - card.clientWidth }; + }, full); + assert(o.doc <= 0 && o.card <= 0, 'horizontal overflow ' + JSON.stringify(o)); + await shot(mp, 'mobile-flood-only', full + ' .node-adverts-intervals'); + await m.close(); + }); + + await step('no page errors', async () => { + assert(errors.length === 0, errors.join(' | ')); + }); + + await browser.close(); + console.log(`\n${passed}/${passed + failed} passed`); + process.exit(failed === 0 ? 0 : 1); +})().catch(e => { console.error(e); process.exit(2); }); diff --git a/test-node-adverts.js b/test-node-adverts.js index 7adf066db..39bc9ced1 100644 --- a/test-node-adverts.js +++ b/test-node-adverts.js @@ -162,6 +162,83 @@ test('escaping: node-controlled text never reaches the HTML raw', () => { assert.ok(html.includes('<script>')); }); +// #245: estimated flood / zero-hop advert intervals under the counts. +const est = (over) => Object.assign({ interval_s: null, raw_interval_s: null, snapped: false, samples: 0, gaps_used: 0, confidence: 'none', status: 'none_observed', last_advert: null }, + over && over.interval_s != null && !('status' in over) ? { status: 'estimated' } : {}, over || {}); +const intervals = (flood, zh) => ({ window: 20, flood: flood, zero_hop: zh }); +function intervalRows(html) { + const out = {}; + const re = /([\s\S]*?)<\/span>(?=)/g; + let m; + while ((m = re.exec(html))) out[m[1]] = m[2].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); + return out; +} + +test('intervals: both classes shown, flood in hours and zero-hop in minutes', () => { + const d = detail({ advertIntervals: intervals( + est({ interval_s: 43200, raw_interval_s: 43195, snapped: true, samples: 10, gaps_used: 7, confidence: 'high', last_advert: '2026-09-25T10:00:00Z' }), + est({ interval_s: 7200, raw_interval_s: 7203, snapped: true, samples: 6, gaps_used: 5, confidence: 'medium', last_advert: '2026-09-25T11:00:00Z' })) }); + const html = NA.render(d, opts()); + const rows = intervalRows(html); + assert.strictEqual(rows.flood, 'Estimated flood interval ≈ 12 h (10 adverts, high confidence)'); + assert.strictEqual(rows.zero_hop, 'Estimated zero-hop interval ≈ 120 min (6 adverts, medium confidence)'); + assert.ok(html.indexOf('node-adverts-intervals') > html.indexOf('node-adverts-counts'), 'below the counts'); + assert.ok(html.indexOf('node-adverts-intervals') < html.indexOf('node-adverts-tabs'), 'above the tabs'); + const tip = (html.match(/class="node-adverts-intervals" title="([^"]*)"/) || [])[1] || ''; + assert.ok(/a gap that repeats/.test(tip), 'tooltip explains the candidate interval: ' + tip); + assert.ok(/raised/.test(tip), 'tooltip explains a raised interval: ' + tip); + assert.ok(/2 min on an untouched new install/.test(tip), 'tooltip names the 2-minute new-install default: ' + tip); + assert.ok(!/median gap is taken/.test(tip), 'no stale median wording'); + const pane = intervalRows(NA.render(d, opts({ variant: 'pane' }))); + assert.strictEqual(pane.flood, rows.flood, 'side panel shows the same'); +}); + +test('intervals: no zero-hop adverts reads as off or out of direct range', () => { + const rows = intervalRows(NA.render(detail({ advertIntervals: intervals( + est({ interval_s: 169200, snapped: true, samples: 14, gaps_used: 12, confidence: 'high' }), est()) }), opts())); + assert.strictEqual(rows.flood, 'Estimated flood interval ≈ 47 h (14 adverts, high confidence)'); + assert.strictEqual(rows.zero_hop, 'Estimated zero-hop interval: none observed (off, or no observer in direct range)'); + const noFlood = intervalRows(NA.render(detail({ advertIntervals: intervals(est(), est()) }), opts())); + assert.strictEqual(noFlood.flood, 'Estimated flood interval: none observed'); +}); + +test('intervals: too few, irregular, low confidence, 2-minute default and unsnapped values', () => { + const rows = (f, z) => intervalRows(NA.render(detail({ advertIntervals: intervals(f, z) }), opts())); + let r = rows(est({ samples: 2, status: 'too_few', last_advert: 'x' }), est({ samples: 1, status: 'too_few', last_advert: 'x' })); + assert.strictEqual(r.flood, 'Estimated flood interval: not enough adverts yet (2 heard)'); + assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval: not enough adverts yet (1 heard)'); + r = rows(est({ samples: 6, status: 'irregular', last_advert: 'x' }), est({ interval_s: 120, snapped: true, samples: 30, gaps_used: 20, confidence: 'high' })); + assert.strictEqual(r.flood, 'Estimated flood interval: irregular (6 adverts, no repeating gap)'); + assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval ≈ 2 min (30 adverts, high confidence)'); + r = rows(est({ interval_s: 9000, raw_interval_s: 9000, samples: 3, gaps_used: 2, confidence: 'low' }), + est({ interval_s: 3000, raw_interval_s: 3000, samples: 4, gaps_used: 3, confidence: 'medium' })); + assert.strictEqual(r.flood, 'Estimated flood interval ≈ 2.5 h (3 adverts, low confidence, outside the settable 3–168 h)'); + assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval ≈ 50 min (4 adverts, medium confidence, outside the settable 60–240 min)'); +}); + +test('intervals: the server status decides the wording, not a client-side minimum', () => { + const rows = (f, z) => intervalRows(NA.render(detail({ advertIntervals: intervals(f, z) }), opts())); + const r = rows(est({ samples: 5, status: 'too_few', last_advert: 'x' }), est({ samples: 2, status: 'irregular', last_advert: 'x' })); + assert.strictEqual(r.flood, 'Estimated flood interval: not enough adverts yet (5 heard)'); + assert.strictEqual(r.zero_hop, 'Estimated zero-hop interval: irregular (2 adverts, no repeating gap)'); + const unknown = rows(est({ samples: 4, status: 'something-new', last_advert: 'x' }), est({ samples: 0 })); + assert.strictEqual(unknown.flood, 'Estimated flood interval: irregular (4 adverts, no repeating gap)', 'an unknown status is no estimate'); + assert.strictEqual(unknown.zero_hop, 'Estimated zero-hop interval: none observed (off, or no observer in direct range)'); + const notEstimated = rows(est({ interval_s: 43200, samples: 9, confidence: 'high', status: 'irregular' }), est()); + assert.strictEqual(notEstimated.flood, 'Estimated flood interval: irregular (9 adverts, no repeating gap)', 'interval_s only counts with status estimated'); +}); + +test('intervals: absent without the field, values escaped', () => { + assert.ok(!NA.render(detail(), opts()).includes('node-adverts-intervals'), 'older server / hidden identity: no block'); + assert.ok(!NA.render(detail({ advertIntervals: null }), opts()).includes('node-adverts-intervals')); + const html = NA.render(detail({ advertIntervals: intervals( + est({ interval_s: 43200, snapped: true, samples: '9', gaps_used: 7, confidence: '' }), + { interval_s: '', samples: 0, confidence: 'high' }) }), opts()); + assert.ok(!html.includes('9'), 'nothing raw: ' + html); + assert.ok(html.includes('<svg onload=alert(2)> confidence'), 'confidence rendered escaped'); + assert.ok(intervalRows(html).zero_hop.includes('none observed'), 'a non-numeric interval is no estimate'); +}); + test('existing badges and signal readouts are kept', () => { const r = row(8, 'flood', { observation_count: 3, snr: 7.5, rssi: -91, observer_name: 'ObsA', raw_hex: '11c1' }); const d = detail({ recentAdverts: [r] });