mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 16:54:58 +00:00
a56ee5c4fe
## Summary Selectable analytics timeframes (#842). Adds backend support for `?window=1h|24h|7d|30d` and `?from=&to=` on the three main analytics endpoints (`/api/analytics/rf`, `/api/analytics/topology`, `/api/analytics/channels`), and a time-window picker in the Analytics page UI that drives them. Default behavior with no query params is unchanged. ## TDD trail - Red: `bbab04d` — adds `TimeWindow` + `ParseTimeWindow` stub and tests; tests fail on assertions because the stub returns the zero window. - Green: `75d27f9` — implements `ParseTimeWindow`, threads `TimeWindow` through `compute*` loops + caches, wires HTTP handlers, adds frontend picker + E2E. ## Backend changes - `cmd/server/time_window.go` — full `ParseTimeWindow` (`?window=` aliases + `?from=/&to=` RFC3339 absolute range; invalid input → zero window for backwards compatibility). - `cmd/server/store.go` — new `GetAnalytics{RF,Topology,Channels}WithWindow` wrappers; `compute*` loops skip transmissions whose `FirstSeen` (or per-obs `Timestamp` for the region+observer slice) falls outside the window. Cache key composes `region|window` so different windows do not poison each other. - `cmd/server/routes.go` — handlers call `ParseTimeWindow(r)` and dispatch to the `*WithWindow` methods. ## Frontend changes - `public/analytics.js` — new `<select id="analyticsTimeWindow">` rendered under the region filter (All / 1h / 24h / 7d / 30d). Selecting an option triggers `loadAnalytics()` which appends `&window=…` to every analytics fetch. ## Tests - `cmd/server/time_window_test.go` — covers all aliases, absolute range, no-params backwards compatibility, `Includes()` bounds, and `CacheKey()` distinctness. - `cmd/server/topology_dedup_test.go`, `cmd/server/channel_analytics_test.go` — updated callers to pass `TimeWindow{}`. ## E2E (rule 18) `test-e2e-playwright.js:592-611` — opens `/#/analytics`, asserts the picker is rendered with a `24h` option, then asserts that selecting `24h` triggers a network request to `/api/analytics/rf?…window=24h`. ## Backwards compatibility No params → zero `TimeWindow` → original code paths (no filter, region-only cache key). Verified by `TestParseTimeWindow_NoParams_BackwardsCompatible` and by the existing analytics tests still passing unchanged on `_wt-fix-842`. Fixes #842 --------- Co-authored-by: you <you@example.com> Co-authored-by: corescope-bot <bot@corescope>
134 lines
3.7 KiB
Go
134 lines
3.7 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// TimeWindow is a half-open time range used to bound analytics queries.
|
|
// Empty Since/Until means unbounded on that end (backwards compatible).
|
|
type TimeWindow struct {
|
|
Since string // RFC3339, empty = unbounded
|
|
Until string // RFC3339, empty = unbounded
|
|
// Label is a stable identifier for the user-requested window
|
|
// (e.g. "24h"). For relative windows it is the original alias; for
|
|
// absolute ranges it is empty (Since/Until are already stable).
|
|
// Used only for cache keying so that "?window=24h" produces a single
|
|
// cache entry instead of one per second.
|
|
Label string
|
|
}
|
|
|
|
// IsZero reports whether the window imposes no bounds at all.
|
|
func (w TimeWindow) IsZero() bool {
|
|
return w.Since == "" && w.Until == ""
|
|
}
|
|
|
|
// CacheKey returns a deterministic key suitable for analytics caches.
|
|
// For relative windows the key is the alias label so that the cache
|
|
// remains stable across the wall-clock advancing.
|
|
func (w TimeWindow) CacheKey() string {
|
|
if w.IsZero() {
|
|
return ""
|
|
}
|
|
if w.Label != "" {
|
|
return "rel:" + w.Label
|
|
}
|
|
return w.Since + "|" + w.Until
|
|
}
|
|
|
|
// Includes reports whether ts (an RFC3339-style string) falls within the
|
|
// window. Empty ts is treated as included (for callers that don't have a
|
|
// timestamp on every observation).
|
|
//
|
|
// Comparison is done by parsing both sides into time.Time. Lex compare is
|
|
// unsafe here because stored timestamps carry millisecond precision
|
|
// ("...HH:MM:SS.000Z") while bounds emitted by ParseTimeWindow do not
|
|
// ("...HH:MM:SSZ"), and '.' (0x2e) sorts before 'Z' (0x5a). If a timestamp
|
|
// fails to parse we fall back to lex compare to preserve old behavior.
|
|
func (w TimeWindow) Includes(ts string) bool {
|
|
if ts == "" {
|
|
return true
|
|
}
|
|
tt, terr := parseAnyRFC3339(ts)
|
|
if w.Since != "" {
|
|
if s, err := parseAnyRFC3339(w.Since); err == nil && terr == nil {
|
|
if tt.Before(s) {
|
|
return false
|
|
}
|
|
} else if ts < w.Since {
|
|
return false
|
|
}
|
|
}
|
|
if w.Until != "" {
|
|
if u, err := parseAnyRFC3339(w.Until); err == nil && terr == nil {
|
|
if tt.After(u) {
|
|
return false
|
|
}
|
|
} else if ts > w.Until {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// parseAnyRFC3339 accepts both fractional-second ("...000Z") and second-
|
|
// precision ("...Z") RFC3339 timestamps. time.RFC3339Nano handles both.
|
|
func parseAnyRFC3339(s string) (time.Time, error) {
|
|
return time.Parse(time.RFC3339Nano, s)
|
|
}
|
|
|
|
// ParseTimeWindow extracts a TimeWindow from query params.
|
|
//
|
|
// Supported parameters:
|
|
//
|
|
// ?window=1h | 24h | 7d | 30d — relative window ending "now"
|
|
// ?from=<RFC3339>&to=<RFC3339> — absolute custom range (either bound optional)
|
|
//
|
|
// When neither is set, returns the zero TimeWindow (unbounded; original behavior).
|
|
// Invalid values are silently ignored to preserve backwards compatibility.
|
|
func ParseTimeWindow(r *http.Request) TimeWindow {
|
|
q := r.URL.Query()
|
|
|
|
// Absolute range takes precedence if either bound is set.
|
|
from := q.Get("from")
|
|
to := q.Get("to")
|
|
if from != "" || to != "" {
|
|
w := TimeWindow{}
|
|
if from != "" {
|
|
if t, err := time.Parse(time.RFC3339, from); err == nil {
|
|
w.Since = t.UTC().Format(time.RFC3339)
|
|
}
|
|
}
|
|
if to != "" {
|
|
if t, err := time.Parse(time.RFC3339, to); err == nil {
|
|
w.Until = t.UTC().Format(time.RFC3339)
|
|
}
|
|
}
|
|
return w
|
|
}
|
|
|
|
// Relative window.
|
|
if win := q.Get("window"); win != "" {
|
|
var d time.Duration
|
|
switch win {
|
|
case "1h":
|
|
d = 1 * time.Hour
|
|
case "24h", "1d":
|
|
d = 24 * time.Hour
|
|
case "3d":
|
|
d = 3 * 24 * time.Hour
|
|
case "7d", "1w":
|
|
d = 7 * 24 * time.Hour
|
|
case "30d":
|
|
d = 30 * 24 * time.Hour
|
|
default:
|
|
// Unknown values are silently ignored — backwards compatible.
|
|
return TimeWindow{}
|
|
}
|
|
since := time.Now().UTC().Add(-d).Format(time.RFC3339)
|
|
return TimeWindow{Since: since, Label: win}
|
|
}
|
|
|
|
return TimeWindow{}
|
|
}
|