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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"Logo": "readonly",
"MAX_HOP_DIST": "readonly",
"MeshAudio": "readonly",
"MyMeshActivity": "readonly",
"MyMeshObservers": "readonly",
"ObserverAirtime": "readonly",
"MeshConfigReady": "readonly",
Expand Down
80 changes: 57 additions & 23 deletions cmd/server/node_activity_24h.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,37 @@ type NodeActivityHour struct {
Count int `json:"count"`
}

// NodeActivityPrevious24h is the 24 hours immediately before the rolling
// window of NodeActivity24h. It exists only as a like-for-like baseline for
// that window, so it carries no hourly detail. Complete is false unless the
// in-memory index is known to cover the whole EARLIER window: an unknown
// history must never be read as a quiet period, and a Count without Complete
// is a lower bound, not a baseline (#304).
type NodeActivityPrevious24h struct {
WindowStart string `json:"windowStart"`
WindowEnd string `json:"windowEnd"`
Count int `json:"count"`
Complete bool `json:"complete"`
}

// NodeActivity24h describes the last 24 rolling hours. Complete is false
// unless the in-memory index is known to cover the whole window.
type NodeActivity24h struct {
WindowStart string `json:"windowStart"`
WindowEnd string `json:"windowEnd"`
Buckets [24]NodeActivityHour `json:"buckets"`
CoverageStart *string `json:"coverageStart"`
Complete bool `json:"complete"`
WindowStart string `json:"windowStart"`
WindowEnd string `json:"windowEnd"`
Buckets [24]NodeActivityHour `json:"buckets"`
CoverageStart *string `json:"coverageStart"`
Complete bool `json:"complete"`
Previous NodeActivityPrevious24h `json:"previous"`
start time.Time
end time.Time
// startSecond is start truncated to whole seconds in the fixed-width
// "2006-01-02T15:04:05" form; add() uses it as a string pre-filter.
startSecond string
// prevStart is the start of Previous's window (start - 24h).
prevStart time.Time
// startSecond and prevStartSecond are start / prevStart truncated to
// whole seconds in the fixed-width "2006-01-02T15:04:05" form; add()
// uses prevStartSecond as its string pre-filter.
startSecond string
prevStartSecond string
}

// nodeActivitySecondLayout is fixed width: no fraction, no zone suffix.
Expand All @@ -30,12 +48,19 @@ const nodeActivitySecondLayout = "2006-01-02T15:04:05"
func newNodeActivity24h(now, coverageStart time.Time) NodeActivity24h {
end := now.UTC()
start := end.Add(-24 * time.Hour)
prevStart := start.Add(-24 * time.Hour)
a := NodeActivity24h{
WindowStart: start.Format(time.RFC3339Nano),
WindowEnd: end.Format(time.RFC3339Nano),
start: start,
end: end,
startSecond: start.Format(nodeActivitySecondLayout),
Previous: NodeActivityPrevious24h{
WindowStart: prevStart.Format(time.RFC3339Nano),
WindowEnd: start.Format(time.RFC3339Nano),
},
start: start,
end: end,
prevStart: prevStart,
startSecond: start.Format(nodeActivitySecondLayout),
prevStartSecond: prevStart.Format(nodeActivitySecondLayout),
}
for i := range a.Buckets {
bucketStart := start.Add(time.Duration(i) * time.Hour)
Expand All @@ -49,6 +74,7 @@ func newNodeActivity24h(now, coverageStart time.Time) NodeActivity24h {
iso := coverageStart.Format(time.RFC3339Nano)
a.CoverageStart = &iso
a.Complete = !coverageStart.After(start)
a.Previous.Complete = !coverageStart.After(prevStart)
}
return a
}
Expand All @@ -58,21 +84,29 @@ func (a *NodeActivity24h) add(tx *StoreTx) {
return
}
// #351 F6/R3: cheap pre-filter before the RFC3339Nano parse, for the
// common case of a node whose in-memory history spans well past 24h.
// It compares against startSecond, the window start truncated to whole
// seconds, NOT WindowStart: RFC3339Nano trims trailing zeros, so the
// fraction widths of WindowStart and a stamp differ ("…:00Z" vs
// "…:00.100Z") and a full-string compare can sort an in-window stamp
// below the bound. The second prefix is fixed width, so a UTC stamp at
// or after the window start always sorts >= startSecond whatever its
// fraction. A stamp in the start's own second survives and the exact
// bounds check below decides it. The upper bound is deliberately NOT
// string-filtered, for the same fraction-width reason.
if tx.FirstSeen < a.startSecond {
// common case of a node whose in-memory history spans well past the
// window. It compares against prevStartSecond, the EARLIEST bound
// (prevStart) truncated to whole seconds, and never against WindowStart:
// RFC3339Nano trims trailing zeros, so the fraction widths of a bound and
// a stamp differ ("…:00Z" vs "…:00.100Z") and a full-string compare can
// sort an in-window stamp below the bound. The second prefix is fixed
// width, so a UTC stamp at or after the bound always sorts >=
// prevStartSecond whatever its fraction. A stamp inside the bound's own
// second survives and the exact bounds checks below decide it. The upper
// bound is deliberately NOT string-filtered, for the same reason.
// #304: the filter admits the previous window too, because one pass fills
// both. That costs the parse for transmissions 24-48h old, the same order
// as the per-transmission work GetNodeHealth already does for every row
// of the index.
if tx.FirstSeen < a.prevStartSecond {
return
}
when, err := time.Parse(time.RFC3339Nano, tx.FirstSeen)
if err != nil || when.Before(a.start) || !when.Before(a.end) {
if err != nil || when.Before(a.prevStart) || !when.Before(a.end) {
return
}
if when.Before(a.start) {
a.Previous.Count++
return
}
idx := int(when.Sub(a.start) / time.Hour)
Expand Down
176 changes: 176 additions & 0 deletions cmd/server/node_activity_304_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,179 @@ func BenchmarkNodeActivity24h30K(b *testing.B) {
}
}
}

// BenchmarkNodeActivity24h30KSpread7d is the review-F6 companion: rows spread
// over seven days, so most of them fall outside both windows and the
// 24-48 h band the previous-window comparison admits is actually exercised.
func BenchmarkNodeActivity24h30KSpread7d(b *testing.B) {
now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC)
const week = 7 * 24 * 60
packets := make([]*StoreTx, 30000)
for i := range packets {
packets[i] = &StoreTx{ID: i + 1, FirstSeen: now.Add(-time.Duration(i*week/len(packets)) * time.Minute).Format(time.RFC3339)}
}
b.ReportAllocs()
b.ResetTimer()
for n := 0; n < b.N; n++ {
a := newNodeActivity24h(now, now.Add(-49*time.Hour))
for _, tx := range packets {
a.add(tx)
}
if a.Buckets[23].Count == 0 || a.Previous.Count == 0 {
b.Fatal("both windows must see rows")
}
}
}

// TestNodeActivityPrevious24hWindowAndCoverage covers the #304 traffic
// requirement: a previous-period comparison needs a like-for-like window and
// must never read a missing history as a quiet one. The previous window is
// the 24 hours immediately before the rolling window, and it is Complete only
// when the proven coverage reaches its own start.
func TestNodeActivityPrevious24hWindowAndCoverage(t *testing.T) {
now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC)
start := now.Add(-24 * time.Hour)
prevStart := start.Add(-24 * time.Hour)

full := newNodeActivity24h(now, prevStart)
if full.Previous.WindowStart != prevStart.Format(time.RFC3339Nano) || full.Previous.WindowEnd != full.WindowStart {
t.Fatalf("previous window must abut the rolling window: %+v", full.Previous)
}
if !full.Previous.Complete || !full.Complete {
t.Fatalf("coverage back to the previous window start proves both windows: %+v", full)
}

// One nanosecond short of the previous window start: the current window
// stays complete, the previous one cannot be compared.
short := newNodeActivity24h(now, prevStart.Add(time.Nanosecond))
if !short.Complete || short.Previous.Complete {
t.Fatalf("coverage after the previous start must not claim it: %+v", short)
}

// 24h coverage only: the previous window is unknown, not zero.
cur := newNodeActivity24h(now, start)
if !cur.Complete || cur.Previous.Complete || cur.Previous.Count != 0 {
t.Fatalf("24h coverage must leave the previous window incomparable: %+v", cur)
}

// No coverage evidence at all: neither window is complete.
if unknown := newNodeActivity24h(now, time.Time{}); unknown.Complete || unknown.Previous.Complete {
t.Fatalf("unknown coverage must not complete either window: %+v", unknown)
}
}

// TestNodeActivityPrevious24hCountsOnlyItsOwnWindow pins the counting
// semantics and the boundaries of the two windows, including the cheap
// string pre-filter: a transmission in the previous window must reach
// Previous.Count and must never land in an hourly bucket.
func TestNodeActivityPrevious24hCountsOnlyItsOwnWindow(t *testing.T) {
now := time.Date(2026, 10, 7, 13, 17, 0, 500_000_000, time.UTC) // fractional now
start := now.Add(-24 * time.Hour)
prevStart := start.Add(-24 * time.Hour)
a := newNodeActivity24h(now, prevStart)

for _, ts := range []time.Time{
prevStart, // inclusive previous start
prevStart.Add(time.Hour), // inside previous
start.Add(-time.Nanosecond), // last instant of previous
} {
a.add(&StoreTx{FirstSeen: ts.Format(time.RFC3339Nano)})
}
a.add(&StoreTx{FirstSeen: prevStart.Add(-time.Nanosecond).Format(time.RFC3339Nano)}) // before both
a.add(&StoreTx{FirstSeen: start.Format(time.RFC3339Nano)}) // first bucket
a.add(&StoreTx{FirstSeen: now.Add(-time.Minute).Format(time.RFC3339Nano)}) // last bucket
a.add(&StoreTx{FirstSeen: now.Format(time.RFC3339Nano)}) // exclusive end
a.add(&StoreTx{FirstSeen: "not a timestamp"})

if a.Previous.Count != 3 {
t.Fatalf("previous count=%d, want 3", a.Previous.Count)
}
if a.Buckets[0].Count != 1 || a.Buckets[23].Count != 1 {
t.Fatalf("buckets[0]=%d [23]=%d, want 1 each", a.Buckets[0].Count, a.Buckets[23].Count)
}
total := 0
for _, b := range a.Buckets {
total += b.Count
}
if total != 2 {
t.Fatalf("rolling window total=%d, want 2 (previous-window rows must not be bucketed)", total)
}

// A stamp inside the previous start's own second survives the string
// pre-filter, which compares against a bound truncated to whole seconds
// (the same fraction-width trap as #351 F6), and the exact bounds check
// then places it. 100ms after a .5s window start is in the window;
// 100ms before it is in neither.
second := newNodeActivity24h(now, prevStart)
second.add(&StoreTx{FirstSeen: prevStart.Add(100 * time.Millisecond).Format(time.RFC3339Nano)})
second.add(&StoreTx{FirstSeen: prevStart.Add(-100 * time.Millisecond).Format(time.RFC3339Nano)})
if second.Previous.Count != 1 {
t.Fatalf("previous boundary second mishandled: %+v", second.Previous)
}
}

// TestNodeHealthPrevious24hThroughJSON proves the comparison baseline reaches
// the client through the real endpoint payload, with the node's own
// transmissions split across the two windows.
func TestNodeHealthPrevious24hThroughJSON(t *testing.T) {
srv, _ := setupTestServer(t)
const pubkey = "aabbccdd11223355"
now := time.Date(2026, 10, 7, 13, 17, 0, 0, time.UTC)
start := now.Add(-24 * time.Hour)
prevStart := start.Add(-24 * time.Hour)
packets := []*StoreTx{
{ID: 1, FirstSeen: prevStart.Format(time.RFC3339), ObservationCount: 2},
{ID: 2, FirstSeen: prevStart.Add(3 * time.Hour).Format(time.RFC3339), ObservationCount: 1},
{ID: 3, FirstSeen: start.Add(time.Hour).Format(time.RFC3339), ObservationCount: 1},
{ID: 4, FirstSeen: prevStart.Add(-time.Hour).Format(time.RFC3339), ObservationCount: 1},
}
srv.store.mu.Lock()
srv.store.byNode[pubkey] = packets
srv.store.loaded = true
srv.store.retentionHours = 168
srv.store.oldestLoaded = prevStart.Add(-2 * time.Hour).Format(time.RFC3339)
srv.store.retainedHistoryFrom = prevStart.Add(-2 * time.Hour)
srv.store.packets = []*StoreTx{{ID: 100, FirstSeen: srv.store.oldestLoaded}}
srv.store.backgroundLoadDone.Store(true)
srv.store.mu.Unlock()

health, err := srv.store.GetNodeHealth(pubkey, now)
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(health)
if err != nil {
t.Fatal(err)
}
var body struct {
Activity24h struct {
Complete bool `json:"complete"`
Buckets []NodeActivityHour `json:"buckets"`
Previous struct {
WindowStart string `json:"windowStart"`
WindowEnd string `json:"windowEnd"`
Count int `json:"count"`
Complete bool `json:"complete"`
} `json:"previous"`
} `json:"activity24h"`
}
if err := json.Unmarshal(encoded, &body); err != nil {
t.Fatal(err)
}
if !body.Activity24h.Complete || !body.Activity24h.Previous.Complete {
t.Fatalf("both windows must be complete: %s", encoded)
}
if body.Activity24h.Previous.Count != 2 {
t.Fatalf("previous count=%d, want 2", body.Activity24h.Previous.Count)
}
if body.Activity24h.Previous.WindowEnd != body.Activity24h.Buckets[0].Start {
t.Fatalf("previous window must end where the rolling window starts: %s", encoded)
}
rolling := 0
for _, b := range body.Activity24h.Buckets {
rolling += b.Count
}
if rolling != 1 {
t.Fatalf("rolling total=%d, want 1", rolling)
}
}
2 changes: 1 addition & 1 deletion cmd/server/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ func heardBySchemas() map[string]*openAPISchema {
"directObservers": direct,
"relayObservers": relay,
"recentPackets": {Type: "array", Items: &openAPISchema{Type: "object"}, Description: "Newest 20 transmissions, observations stripped."},
"activity24h": {Type: "object", Description: "24 rolling one-hour buckets of the node's transmissions."},
"activity24h": {Type: "object", Description: "The rolling 24-hour window of the node's transmissions: 24 one-hour buckets, windowStart/windowEnd, coverageStart and complete, plus previous {windowStart, windowEnd, count, complete} — the equally long window before it, for a like-for-like comparison only (#304). A window is complete only when the in-memory history is proven to cover all of it; an incomplete window is a lower bound, never a quiet period."},
},
},
"BulkHealthEntry": {
Expand Down
22 changes: 20 additions & 2 deletions public/home.css
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,10 @@
border-radius: 6px; padding: 10px 12px; background: var(--surface-2);
}

.mnc-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 12px; }
/* #304: three metrics (rolling-24h traffic, direct observers, direct SNR);
the hop average moved into the evidence disclosure. Three columns at every
width keeps them on ONE row, which pays for the disclosure summary line. */
.mnc-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
.mnc-metric { text-align: center; }
.mnc-val { font: 700 1rem var(--font); color: var(--text); }
.mnc-lbl { font-size: .65rem; color: var(--text-muted); margin-top: 2px; }
Expand All @@ -536,6 +539,22 @@
.mnc-advert-provisional { color: var(--status-yellow); font-size: .7rem; }
.mnc-advert-note { color: var(--text-muted); font-size: .68rem; }

/* #304 — status evidence and metric scope.
The disclosure is closed by default so the card stays compact, and it is a
native <details>, which is reachable by keyboard and by touch (a title
tooltip is neither). A status badge that rests only on an attributed relay
hash prefix must not read as a confident green, so the accent drops to the
provisional colour; every colour here is a theme variable. */
.mnc-evidence { font-size: .72rem; line-height: 1.35; color: var(--text-muted); margin: -12px 0 6px; padding-left: 28px; }
.mnc-evidence > summary { cursor: pointer; color: var(--accent); width: fit-content; min-height: 24px; display: flex; align-items: center; padding-right: 8px; }
.mnc-evidence > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.mnc-evidence-row { display: flex; flex-wrap: wrap; gap: .35em; line-height: 1.4; }
.mnc-evidence-row strong { color: var(--text); font-weight: 600; }
.mnc-evidence-note { margin: 2px 0 4px; line-height: 1.4; }
.mnc-metric-sub { font-size: .62rem; font-weight: 400; color: var(--text-muted); }
.health-metric .mnc-metric-sub { display: block; margin-top: 2px; }
.my-node-card.healthy.evidence-relay { border-left-color: var(--status-yellow); }

.mnc-spark { margin-bottom: 8px; }
.home-spark-unavailable { font-size: .7rem; color: var(--text-muted); }
.spark-label { font-size: .65rem; color: var(--text-muted); margin-bottom: 4px; }
Expand Down Expand Up @@ -567,7 +586,6 @@
/* Responsive */
@media (max-width: 640px) {
.my-nodes-grid { grid-template-columns: 1fr; padding: 0 12px 16px; }
.mnc-metrics { grid-template-columns: repeat(2, 1fr); }
.mnc-remove { opacity: 1; }
/* #262 — one column on a phone: the nowrap traffic label would otherwise
squeeze the status rows below their own text width. */
Expand Down
Loading
Loading