mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-24 02:52:22 +00:00
feat: RF Health Dashboard M1 — observer metrics + small multiples grid (#604)
## RF Health Dashboard — M1: Observer Metrics Storage, API & Small Multiples Grid Implements M1 of #600. ### What this does Adds a complete RF health monitoring pipeline: MQTT stats ingestion → SQLite storage → REST API → interactive dashboard with small multiples grid. ### Backend Changes **Ingestor (`cmd/ingestor/`)** - New `observer_metrics` table via migration system (`_migrations` pattern) - Parse `tx_air_secs`, `rx_air_secs`, `recv_errors` from MQTT status messages (same pattern as existing `noise_floor` and `battery_mv`) - `INSERT OR REPLACE` with timestamps rounded to nearest 5-min interval boundary (using ingestor wall clock, not observer timestamps) - Missing fields stored as NULLs — partial data is always better than no data - Configurable retention pruning: `retention.metricsDays` (default 30), runs on startup + every 24h **Server (`cmd/server/`)** - `GET /api/observers/{id}/metrics?since=...&until=...` — per-observer time-series data - `GET /api/observers/metrics/summary?window=24h` — fleet summary with current NF, avg/max NF, sample count - `parseWindowDuration()` supports `1h`, `24h`, `3d`, `7d`, `30d` etc. - Server-side metrics retention pruning (same config, staggered 2min after packet prune) ### Frontend Changes **RF Health tab (`public/analytics.js`, `public/style.css`)** - Small multiples grid showing all observers simultaneously — anomalies pop out visually - Per-observer cell: name, current NF value, battery voltage, sparkline, avg/max stats - NF status coloring: warning (amber) at ≥-100 dBm, critical (red) at ≥-85 dBm — text color only, no background fills - Click any cell → expanded detail view with full noise floor line chart - Reference lines with direct text labels (`-100 warning`, `-85 critical`) — not color bands - Min/max points labeled directly on the chart - Time range selector: preset buttons (1h/3h/6h/12h/24h/3d/7d/30d) + custom from/to datetime picker - Deep linking: `#/analytics?tab=rf-health&observer=...&range=...` - All charts use SVG, matching existing analytics.js patterns - Responsive: 3-4 columns on desktop, 1 on mobile ### Design Decisions (from spec) - Labels directly on data, not in legends - Reference lines with text labels, not color bands - Small multiples grid, not card+accordion (Tufte: instant visual fleet comparison) - Ingestor wall clock for all timestamps (observer clocks may drift) ### Tests Added **Ingestor tests:** - `TestRoundToInterval` — 5 cases for rounding to 5-min boundaries - `TestInsertMetrics` — basic insertion with all fields - `TestInsertMetricsIdempotent` — INSERT OR REPLACE deduplication - `TestInsertMetricsNullFields` — partial data with NULLs - `TestPruneOldMetrics` — retention pruning - `TestExtractObserverMetaNewFields` — parsing tx_air_secs, rx_air_secs, recv_errors **Server tests:** - `TestGetObserverMetrics` — time-series query with since/until filters, NULL handling - `TestGetMetricsSummary` — fleet summary aggregation - `TestObserverMetricsAPIEndpoints` — DB query verification - `TestMetricsAPIEndpoints` — HTTP endpoint response shape - `TestParseWindowDuration` — duration parsing for h/d formats ### Test Results ``` cd cmd/ingestor && go test ./... → PASS (26s) cd cmd/server && go test ./... → PASS (5s) ``` ### What's NOT in this PR (deferred to M2+) - Server-side delta computation for cumulative counters - Airtime charts (TX/RX percentage lines) - Channel quality chart (recv_error_rate) - Battery voltage chart - Reboot detection and chart annotations - Resolution downsampling (1h, 1d aggregates) - Pattern detection / automated diagnosis --------- Co-authored-by: you <you@example.com>
This commit is contained in:
1 parent
aaf00d0616
commit
6f35d4d417
12 files changed
+1244
-5
No files matched your search
+24
-1
@@ -36,6 +36,7 @@ type Config struct {
|
||||
ChannelKeys map[string]string `json:"channelKeys,omitempty"`
|
||||
HashChannels []string `json:"hashChannels,omitempty"`
|
||||
Retention *RetentionConfig `json:"retention,omitempty"`
|
||||
Metrics *MetricsConfig `json:"metrics,omitempty"`
|
||||
GeoFilter *GeoFilterConfig `json:"geo_filter,omitempty"`
|
||||
}
|
||||
|
||||
@@ -44,7 +45,29 @@ type GeoFilterConfig = geofilter.Config
|
||||
|
||||
// RetentionConfig controls how long stale nodes are kept before being moved to inactive_nodes.
|
||||
type RetentionConfig struct {
|
||||
NodeDays int `json:"nodeDays"`
|
||||
NodeDays int `json:"nodeDays"`
|
||||
MetricsDays int `json:"metricsDays"`
|
||||
}
|
||||
|
||||
// MetricsConfig controls observer metrics collection.
|
||||
type MetricsConfig struct {
|
||||
SampleIntervalSec int `json:"sampleIntervalSec"`
|
||||
}
|
||||
|
||||
// MetricsSampleInterval returns the configured sample interval or 300s default.
|
||||
func (c *Config) MetricsSampleInterval() int {
|
||||
if c.Metrics != nil && c.Metrics.SampleIntervalSec > 0 {
|
||||
return c.Metrics.SampleIntervalSec
|
||||
}
|
||||
return 300
|
||||
}
|
||||
|
||||
// MetricsRetentionDays returns configured metrics retention or 30 days default.
|
||||
func (c *Config) MetricsRetentionDays() int {
|
||||
if c.Retention != nil && c.Retention.MetricsDays > 0 {
|
||||
return c.Retention.MetricsDays
|
||||
}
|
||||
return 30
|
||||
}
|
||||
|
||||
// NodeDaysOrDefault returns the configured retention.nodeDays or 7 if not set.
|
||||
|
||||
+120
-1
@@ -39,11 +39,19 @@ type Store struct {
|
||||
stmtGetObserverRowid *sql.Stmt
|
||||
stmtUpdateObserverLastSeen *sql.Stmt
|
||||
stmtUpdateNodeTelemetry *sql.Stmt
|
||||
stmtUpsertMetrics *sql.Stmt
|
||||
|
||||
sampleIntervalSec int
|
||||
}
|
||||
|
||||
// OpenStore opens or creates a SQLite DB at the given path, applying the
|
||||
// v3 schema that is compatible with the Node.js server.
|
||||
func OpenStore(dbPath string) (*Store, error) {
|
||||
return OpenStoreWithInterval(dbPath, 300)
|
||||
}
|
||||
|
||||
// OpenStoreWithInterval opens or creates a SQLite DB with a configurable sample interval.
|
||||
func OpenStoreWithInterval(dbPath string, sampleIntervalSec int) (*Store, error) {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("creating data dir: %w", err)
|
||||
@@ -66,7 +74,7 @@ func OpenStore(dbPath string) (*Store, error) {
|
||||
return nil, fmt.Errorf("applying schema: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db}
|
||||
s := &Store{db: db, sampleIntervalSec: sampleIntervalSec}
|
||||
if err := s.prepareStatements(); err != nil {
|
||||
return nil, fmt.Errorf("preparing statements: %w", err)
|
||||
}
|
||||
@@ -292,6 +300,41 @@ func applySchema(db *sql.DB) error {
|
||||
log.Println("[migration] observations timestamp index created")
|
||||
}
|
||||
|
||||
// observer_metrics table for RF health dashboard
|
||||
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'observer_metrics_v1'")
|
||||
if row.Scan(&migDone) != nil {
|
||||
log.Println("[migration] Creating observer_metrics table...")
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS observer_metrics (
|
||||
observer_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
noise_floor REAL,
|
||||
tx_air_secs INTEGER,
|
||||
rx_air_secs INTEGER,
|
||||
recv_errors INTEGER,
|
||||
battery_mv INTEGER,
|
||||
PRIMARY KEY (observer_id, timestamp)
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("observer_metrics schema: %w", err)
|
||||
}
|
||||
db.Exec(`INSERT INTO _migrations (name) VALUES ('observer_metrics_v1')`)
|
||||
log.Println("[migration] observer_metrics table created")
|
||||
}
|
||||
|
||||
// Migration: add timestamp index for cross-observer time-range queries
|
||||
row = db.QueryRow("SELECT 1 FROM _migrations WHERE name = 'observer_metrics_ts_idx'")
|
||||
if row.Scan(&migDone) != nil {
|
||||
log.Println("[migration] Creating observer_metrics timestamp index...")
|
||||
_, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_observer_metrics_timestamp ON observer_metrics(timestamp)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("observer_metrics timestamp index: %w", err)
|
||||
}
|
||||
db.Exec(`INSERT INTO _migrations (name) VALUES ('observer_metrics_ts_idx')`)
|
||||
log.Println("[migration] observer_metrics timestamp index created")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -385,6 +428,14 @@ func (s *Store) prepareStatements() error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.stmtUpsertMetrics, err = s.db.Prepare(`
|
||||
INSERT OR REPLACE INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, battery_mv)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -517,6 +568,9 @@ type ObserverMeta struct {
|
||||
BatteryMv *int // millivolts, always integer
|
||||
UptimeSecs *int64 // seconds, always integer
|
||||
NoiseFloor *float64 // dBm, may have decimals
|
||||
TxAirSecs *int // cumulative TX seconds since boot
|
||||
RxAirSecs *int // cumulative RX seconds since boot
|
||||
RecvErrors *int // cumulative CRC/decode failures since boot
|
||||
}
|
||||
|
||||
// UpsertObserver inserts or updates an observer with optional hardware metadata.
|
||||
@@ -568,6 +622,71 @@ func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// RoundToInterval rounds a time to the nearest sample interval boundary.
|
||||
func RoundToInterval(t time.Time, intervalSec int) time.Time {
|
||||
if intervalSec <= 0 {
|
||||
intervalSec = 300
|
||||
}
|
||||
epoch := t.Unix()
|
||||
half := int64(intervalSec) / 2
|
||||
rounded := ((epoch + half) / int64(intervalSec)) * int64(intervalSec)
|
||||
return time.Unix(rounded, 0).UTC()
|
||||
}
|
||||
|
||||
// MetricsData holds the fields to insert into observer_metrics.
|
||||
type MetricsData struct {
|
||||
ObserverID string
|
||||
NoiseFloor *float64
|
||||
TxAirSecs *int
|
||||
RxAirSecs *int
|
||||
RecvErrors *int
|
||||
BatteryMv *int
|
||||
}
|
||||
|
||||
// InsertMetrics inserts a metrics sample for an observer using ingestor wall clock.
|
||||
func (s *Store) InsertMetrics(data *MetricsData) error {
|
||||
ts := RoundToInterval(time.Now().UTC(), s.sampleIntervalSec)
|
||||
tsStr := ts.Format(time.RFC3339)
|
||||
|
||||
var nf, txAir, rxAir, recvErr, batt interface{}
|
||||
if data.NoiseFloor != nil {
|
||||
nf = *data.NoiseFloor
|
||||
}
|
||||
if data.TxAirSecs != nil {
|
||||
txAir = *data.TxAirSecs
|
||||
}
|
||||
if data.RxAirSecs != nil {
|
||||
rxAir = *data.RxAirSecs
|
||||
}
|
||||
if data.RecvErrors != nil {
|
||||
recvErr = *data.RecvErrors
|
||||
}
|
||||
if data.BatteryMv != nil {
|
||||
batt = *data.BatteryMv
|
||||
}
|
||||
|
||||
_, err := s.stmtUpsertMetrics.Exec(data.ObserverID, tsStr, nf, txAir, rxAir, recvErr, batt)
|
||||
if err != nil {
|
||||
s.Stats.WriteErrors.Add(1)
|
||||
return fmt.Errorf("insert metrics: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneOldMetrics deletes observer_metrics rows older than retentionDays.
|
||||
func (s *Store) PruneOldMetrics(retentionDays int) (int64, error) {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays).Format(time.RFC3339)
|
||||
result, err := s.db.Exec(`DELETE FROM observer_metrics WHERE timestamp < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune metrics: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n > 0 {
|
||||
log.Printf("[metrics] Pruned %d rows older than %d days", n, retentionDays)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Checkpoint forces a WAL checkpoint to release the WAL lock file,
|
||||
// preventing lock contention with a new process starting up.
|
||||
func (s *Store) Checkpoint() {
|
||||
|
||||
@@ -1703,3 +1703,182 @@ func TestInsertTransmissionWithScoreAndDirection(t *testing.T) {
|
||||
}
|
||||
|
||||
func ptrFloat(f float64) *float64 { return &f }
|
||||
func ptrInt(i int) *int { return &i }
|
||||
|
||||
func TestRoundToInterval(t *testing.T) {
|
||||
tests := []struct {
|
||||
input time.Time
|
||||
interval int
|
||||
want time.Time
|
||||
}{
|
||||
{time.Date(2026, 4, 5, 10, 2, 0, 0, time.UTC), 300, time.Date(2026, 4, 5, 10, 0, 0, 0, time.UTC)},
|
||||
{time.Date(2026, 4, 5, 10, 3, 0, 0, time.UTC), 300, time.Date(2026, 4, 5, 10, 5, 0, 0, time.UTC)},
|
||||
{time.Date(2026, 4, 5, 10, 2, 30, 0, time.UTC), 300, time.Date(2026, 4, 5, 10, 5, 0, 0, time.UTC)},
|
||||
{time.Date(2026, 4, 5, 10, 5, 0, 0, time.UTC), 300, time.Date(2026, 4, 5, 10, 5, 0, 0, time.UTC)},
|
||||
{time.Date(2026, 4, 5, 10, 7, 29, 0, time.UTC), 300, time.Date(2026, 4, 5, 10, 5, 0, 0, time.UTC)},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := RoundToInterval(tc.input, tc.interval)
|
||||
if !got.Equal(tc.want) {
|
||||
t.Errorf("RoundToInterval(%v, %d) = %v, want %v", tc.input, tc.interval, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertMetrics(t *testing.T) {
|
||||
store, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
nf := -112.5
|
||||
txAir := 100
|
||||
rxAir := 500
|
||||
recvErr := 3
|
||||
batt := 3720
|
||||
data := &MetricsData{
|
||||
ObserverID: "obs1",
|
||||
NoiseFloor: &nf,
|
||||
TxAirSecs: &txAir,
|
||||
RxAirSecs: &rxAir,
|
||||
RecvErrors: &recvErr,
|
||||
BatteryMv: &batt,
|
||||
}
|
||||
|
||||
if err := store.InsertMetrics(data); err != nil {
|
||||
t.Fatalf("InsertMetrics: %v", err)
|
||||
}
|
||||
|
||||
// Verify insertion
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM observer_metrics WHERE observer_id = 'obs1'").Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 row, got %d", count)
|
||||
}
|
||||
|
||||
// Verify values
|
||||
var gotNF float64
|
||||
var gotTx, gotRx, gotErr, gotBatt int
|
||||
store.db.QueryRow("SELECT noise_floor, tx_air_secs, rx_air_secs, recv_errors, battery_mv FROM observer_metrics WHERE observer_id = 'obs1'").Scan(&gotNF, &gotTx, &gotRx, &gotErr, &gotBatt)
|
||||
if gotNF != -112.5 {
|
||||
t.Errorf("noise_floor = %v, want -112.5", gotNF)
|
||||
}
|
||||
if gotTx != 100 {
|
||||
t.Errorf("tx_air_secs = %d, want 100", gotTx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertMetricsIdempotent(t *testing.T) {
|
||||
store, err := OpenStoreWithInterval(tempDBPath(t), 300)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
nf := -110.0
|
||||
data := &MetricsData{ObserverID: "obs1", NoiseFloor: &nf}
|
||||
|
||||
// Insert twice — should result in 1 row (INSERT OR REPLACE)
|
||||
store.InsertMetrics(data)
|
||||
nf2 := -108.0
|
||||
data.NoiseFloor = &nf2
|
||||
store.InsertMetrics(data)
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM observer_metrics WHERE observer_id = 'obs1'").Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 row (idempotent), got %d", count)
|
||||
}
|
||||
|
||||
// Verify the value was replaced
|
||||
var gotNF float64
|
||||
store.db.QueryRow("SELECT noise_floor FROM observer_metrics WHERE observer_id = 'obs1'").Scan(&gotNF)
|
||||
if gotNF != -108.0 {
|
||||
t.Errorf("noise_floor = %v, want -108.0 (replaced)", gotNF)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertMetricsNullFields(t *testing.T) {
|
||||
store, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
nf := -115.0
|
||||
data := &MetricsData{
|
||||
ObserverID: "obs1",
|
||||
NoiseFloor: &nf,
|
||||
// All other fields nil
|
||||
}
|
||||
|
||||
if err := store.InsertMetrics(data); err != nil {
|
||||
t.Fatalf("InsertMetrics with nulls: %v", err)
|
||||
}
|
||||
|
||||
var gotNF sql.NullFloat64
|
||||
var gotTx sql.NullInt64
|
||||
store.db.QueryRow("SELECT noise_floor, tx_air_secs FROM observer_metrics WHERE observer_id = 'obs1'").Scan(&gotNF, &gotTx)
|
||||
if !gotNF.Valid || gotNF.Float64 != -115.0 {
|
||||
t.Errorf("noise_floor = %v, want -115.0", gotNF)
|
||||
}
|
||||
if gotTx.Valid {
|
||||
t.Errorf("tx_air_secs should be NULL, got %v", gotTx.Int64)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneOldMetrics(t *testing.T) {
|
||||
store, err := OpenStore(tempDBPath(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Insert old and new metrics directly
|
||||
oldTs := time.Now().UTC().AddDate(0, 0, -40).Format(time.RFC3339)
|
||||
newTs := time.Now().UTC().Format(time.RFC3339)
|
||||
store.db.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)", "obs1", oldTs, -110.0)
|
||||
store.db.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)", "obs1", newTs, -112.0)
|
||||
|
||||
n, err := store.PruneOldMetrics(30)
|
||||
if err != nil {
|
||||
t.Fatalf("PruneOldMetrics: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("pruned %d rows, want 1", n)
|
||||
}
|
||||
|
||||
var count int
|
||||
store.db.QueryRow("SELECT COUNT(*) FROM observer_metrics").Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 row remaining, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractObserverMetaNewFields(t *testing.T) {
|
||||
msg := map[string]interface{}{
|
||||
"model": "L1",
|
||||
"stats": map[string]interface{}{
|
||||
"noise_floor": -112.5,
|
||||
"battery_mv": 3720.0,
|
||||
"uptime_secs": 86400.0,
|
||||
"tx_air_secs": 100.0,
|
||||
"rx_air_secs": 500.0,
|
||||
"recv_errors": 3.0,
|
||||
},
|
||||
}
|
||||
meta := extractObserverMeta(msg)
|
||||
if meta == nil {
|
||||
t.Fatal("expected non-nil meta")
|
||||
}
|
||||
if meta.TxAirSecs == nil || *meta.TxAirSecs != 100 {
|
||||
t.Errorf("TxAirSecs = %v, want 100", meta.TxAirSecs)
|
||||
}
|
||||
if meta.RxAirSecs == nil || *meta.RxAirSecs != 500 {
|
||||
t.Errorf("RxAirSecs = %v, want 500", meta.RxAirSecs)
|
||||
}
|
||||
if meta.RecvErrors == nil || *meta.RecvErrors != 3 {
|
||||
t.Errorf("RecvErrors = %v, want 3", meta.RecvErrors)
|
||||
}
|
||||
}
|
||||
+49
-1
@@ -53,7 +53,7 @@ func main() {
|
||||
log.Fatal("no MQTT sources configured — set mqttSources in config or MQTT_BROKER env var")
|
||||
}
|
||||
|
||||
store, err := OpenStore(cfg.DBPath)
|
||||
store, err := OpenStoreWithInterval(cfg.DBPath, cfg.MetricsSampleInterval())
|
||||
if err != nil {
|
||||
log.Fatalf("db: %v", err)
|
||||
}
|
||||
@@ -64,6 +64,10 @@ func main() {
|
||||
nodeDays := cfg.NodeDaysOrDefault()
|
||||
store.MoveStaleNodes(nodeDays)
|
||||
|
||||
// Metrics retention: prune old metrics on startup
|
||||
metricsDays := cfg.MetricsRetentionDays()
|
||||
store.PruneOldMetrics(metricsDays)
|
||||
|
||||
// Daily ticker for node retention
|
||||
retentionTicker := time.NewTicker(1 * time.Hour)
|
||||
go func() {
|
||||
@@ -72,6 +76,14 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Daily ticker for metrics retention (every 24h)
|
||||
metricsRetentionTicker := time.NewTicker(24 * time.Hour)
|
||||
go func() {
|
||||
for range metricsRetentionTicker.C {
|
||||
store.PruneOldMetrics(metricsDays)
|
||||
}
|
||||
}()
|
||||
|
||||
// Periodic stats logging (every 5 minutes)
|
||||
statsTicker := time.NewTicker(5 * time.Minute)
|
||||
go func() {
|
||||
@@ -163,6 +175,7 @@ func main() {
|
||||
|
||||
log.Println("Shutting down...")
|
||||
retentionTicker.Stop()
|
||||
metricsRetentionTicker.Stop()
|
||||
statsTicker.Stop()
|
||||
store.LogStats() // final stats on shutdown
|
||||
for _, c := range clients {
|
||||
@@ -215,6 +228,20 @@ func handleMessage(store *Store, tag string, source MQTTSource, m mqtt.Message,
|
||||
if err := store.UpsertObserver(observerID, name, iata, meta); err != nil {
|
||||
log.Printf("MQTT [%s] observer status error: %v", tag, err)
|
||||
}
|
||||
// Insert metrics sample from status message
|
||||
if meta != nil {
|
||||
metricsData := &MetricsData{
|
||||
ObserverID: observerID,
|
||||
NoiseFloor: meta.NoiseFloor,
|
||||
TxAirSecs: meta.TxAirSecs,
|
||||
RxAirSecs: meta.RxAirSecs,
|
||||
RecvErrors: meta.RecvErrors,
|
||||
BatteryMv: meta.BatteryMv,
|
||||
}
|
||||
if err := store.InsertMetrics(metricsData); err != nil {
|
||||
log.Printf("MQTT [%s] metrics insert error: %v", tag, err)
|
||||
}
|
||||
}
|
||||
log.Printf("MQTT [%s] status: %s (%s)", tag, firstNonEmpty(name, observerID), iata)
|
||||
return
|
||||
}
|
||||
@@ -616,6 +643,27 @@ func extractObserverMeta(msg map[string]interface{}) *ObserverMeta {
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
if v := nestedOrTopLevel(stats, msg, "tx_air_secs"); v != nil {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
iv := int(math.Round(f))
|
||||
meta.TxAirSecs = &iv
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
if v := nestedOrTopLevel(stats, msg, "rx_air_secs"); v != nil {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
iv := int(math.Round(f))
|
||||
meta.RxAirSecs = &iv
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
if v := nestedOrTopLevel(stats, msg, "recv_errors"); v != nil {
|
||||
if f, ok := toFloat64(v); ok {
|
||||
iv := int(math.Round(f))
|
||||
meta.RecvErrors = &iv
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasData {
|
||||
return nil
|
||||
|
||||
+11
-2
@@ -69,8 +69,17 @@ type PacketStoreConfig struct {
|
||||
type GeoFilterConfig = geofilter.Config
|
||||
|
||||
type RetentionConfig struct {
|
||||
NodeDays int `json:"nodeDays"`
|
||||
PacketDays int `json:"packetDays"`
|
||||
NodeDays int `json:"nodeDays"`
|
||||
PacketDays int `json:"packetDays"`
|
||||
MetricsDays int `json:"metricsDays"`
|
||||
}
|
||||
|
||||
// MetricsRetentionDays returns configured metrics retention or 30 days default.
|
||||
func (c *Config) MetricsRetentionDays() int {
|
||||
if c.Retention != nil && c.Retention.MetricsDays > 0 {
|
||||
return c.Retention.MetricsDays
|
||||
}
|
||||
return 30
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1734,3 +1734,154 @@ func (db *DB) PruneOldPackets(days int) (int64, error) {
|
||||
n, _ := res.RowsAffected()
|
||||
return n, tx.Commit()
|
||||
}
|
||||
|
||||
// MetricsSample represents a single row from observer_metrics.
|
||||
type MetricsSample struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
NoiseFloor *float64 `json:"noise_floor"`
|
||||
TxAirSecs *int `json:"tx_air_secs"`
|
||||
RxAirSecs *int `json:"rx_air_secs"`
|
||||
RecvErrors *int `json:"recv_errors"`
|
||||
BatteryMv *int `json:"battery_mv"`
|
||||
}
|
||||
|
||||
// GetObserverMetrics returns time-series metrics for a single observer.
|
||||
func (db *DB) GetObserverMetrics(observerID, since, until string) ([]MetricsSample, error) {
|
||||
query := `SELECT timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, battery_mv
|
||||
FROM observer_metrics WHERE observer_id = ?`
|
||||
args := []interface{}{observerID}
|
||||
if since != "" {
|
||||
query += " AND timestamp >= ?"
|
||||
args = append(args, since)
|
||||
}
|
||||
if until != "" {
|
||||
query += " AND timestamp <= ?"
|
||||
args = append(args, until)
|
||||
}
|
||||
query += " ORDER BY timestamp ASC"
|
||||
|
||||
rows, err := db.conn.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []MetricsSample
|
||||
for rows.Next() {
|
||||
var s MetricsSample
|
||||
if err := rows.Scan(&s.Timestamp, &s.NoiseFloor, &s.TxAirSecs, &s.RxAirSecs, &s.RecvErrors, &s.BatteryMv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// MetricsSummaryRow holds summary data for one observer.
|
||||
type MetricsSummaryRow struct {
|
||||
ObserverID string `json:"observer_id"`
|
||||
ObserverName *string `json:"observer_name"`
|
||||
CurrentNF *float64 `json:"current_noise_floor"`
|
||||
AvgNF *float64 `json:"avg_noise_floor_24h"`
|
||||
MaxNF *float64 `json:"max_noise_floor_24h"`
|
||||
CurrentBattMv *int `json:"battery_mv"`
|
||||
SampleCount int `json:"sample_count"`
|
||||
Sparkline []*float64 `json:"sparkline"`
|
||||
}
|
||||
|
||||
// GetMetricsSummary returns a fleet summary of observer metrics within a time window.
|
||||
// Uses a CTE with ROW_NUMBER to get latest values in a single pass (no correlated subqueries).
|
||||
// Also returns sparkline data (noise_floor time series) per observer.
|
||||
func (db *DB) GetMetricsSummary(since string) ([]MetricsSummaryRow, error) {
|
||||
query := `
|
||||
WITH ranked AS (
|
||||
SELECT observer_id, noise_floor, battery_mv,
|
||||
ROW_NUMBER() OVER (PARTITION BY observer_id ORDER BY timestamp DESC) as rn
|
||||
FROM observer_metrics
|
||||
WHERE timestamp >= ?
|
||||
)
|
||||
SELECT m.observer_id, o.name,
|
||||
r.noise_floor as current_nf,
|
||||
AVG(m.noise_floor) as avg_nf,
|
||||
MAX(m.noise_floor) as max_nf,
|
||||
r.battery_mv as current_batt,
|
||||
COUNT(*) as sample_count
|
||||
FROM observer_metrics m
|
||||
LEFT JOIN observers o ON o.id = m.observer_id
|
||||
LEFT JOIN ranked r ON r.observer_id = m.observer_id AND r.rn = 1
|
||||
WHERE m.timestamp >= ?
|
||||
GROUP BY m.observer_id
|
||||
ORDER BY max_nf DESC
|
||||
`
|
||||
rows, err := db.conn.Query(query, since, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []MetricsSummaryRow
|
||||
for rows.Next() {
|
||||
var s MetricsSummaryRow
|
||||
if err := rows.Scan(&s.ObserverID, &s.ObserverName, &s.CurrentNF, &s.AvgNF, &s.MaxNF, &s.CurrentBattMv, &s.SampleCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch sparkline data (noise_floor series) for all observers in one query
|
||||
if len(result) > 0 {
|
||||
sparkQuery := `SELECT observer_id, noise_floor FROM observer_metrics
|
||||
WHERE timestamp >= ? ORDER BY observer_id, timestamp ASC`
|
||||
sparkRows, err := db.conn.Query(sparkQuery, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sparkRows.Close()
|
||||
|
||||
sparkMap := make(map[string][]*float64)
|
||||
for sparkRows.Next() {
|
||||
var oid string
|
||||
var nf *float64
|
||||
if err := sparkRows.Scan(&oid, &nf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sparkMap[oid] = append(sparkMap[oid], nf)
|
||||
}
|
||||
if err := sparkRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range result {
|
||||
if s, ok := sparkMap[result[i].ObserverID]; ok {
|
||||
result[i].Sparkline = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PruneOldMetrics deletes observer_metrics rows older than retentionDays.
|
||||
func (db *DB) PruneOldMetrics(retentionDays int) (int64, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_busy_timeout=10000", db.path)
|
||||
rw, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rw.SetMaxOpenConns(1)
|
||||
defer rw.Close()
|
||||
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays).Format(time.RFC3339)
|
||||
res, err := rw.Exec(`DELETE FROM observer_metrics WHERE timestamp < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n > 0 {
|
||||
log.Printf("[metrics] Pruned %d observer_metrics rows older than %d days", n, retentionDays)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -75,6 +75,19 @@ func setupTestDB(t *testing.T) *DB {
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS observer_metrics (
|
||||
observer_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
noise_floor REAL,
|
||||
tx_air_secs INTEGER,
|
||||
rx_air_secs INTEGER,
|
||||
recv_errors INTEGER,
|
||||
battery_mv INTEGER,
|
||||
PRIMARY KEY (observer_id, timestamp)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_observer_metrics_timestamp ON observer_metrics(timestamp);
|
||||
|
||||
`
|
||||
if _, err := conn.Exec(schema); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1537,3 +1550,148 @@ func TestNodeTelemetryFields(t *testing.T) {
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestGetObserverMetrics(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
|
||||
now := time.Now().UTC()
|
||||
t1 := now.Add(-2 * time.Hour).Format(time.RFC3339)
|
||||
t2 := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
t3 := now.Format(time.RFC3339)
|
||||
|
||||
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, tx_air_secs, rx_air_secs, recv_errors, battery_mv) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"obs1", t1, -112.5, 100, 500, 3, 3720)
|
||||
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
|
||||
"obs1", t2, -110.0)
|
||||
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
|
||||
"obs1", t3, -108.0)
|
||||
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
|
||||
"obs2", t1, -115.0)
|
||||
|
||||
// Query all for obs1
|
||||
since := now.Add(-3 * time.Hour).Format(time.RFC3339)
|
||||
metrics, err := db.GetObserverMetrics("obs1", since, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metrics) != 3 {
|
||||
t.Errorf("expected 3 metrics, got %d", len(metrics))
|
||||
}
|
||||
|
||||
// Verify first row has all fields
|
||||
if metrics[0].NoiseFloor == nil || *metrics[0].NoiseFloor != -112.5 {
|
||||
t.Errorf("first noise_floor = %v, want -112.5", metrics[0].NoiseFloor)
|
||||
}
|
||||
if metrics[0].TxAirSecs == nil || *metrics[0].TxAirSecs != 100 {
|
||||
t.Errorf("first tx_air_secs = %v, want 100", metrics[0].TxAirSecs)
|
||||
}
|
||||
|
||||
// Second row has NULL for tx_air_secs
|
||||
if metrics[1].TxAirSecs != nil {
|
||||
t.Errorf("second tx_air_secs should be nil, got %v", *metrics[1].TxAirSecs)
|
||||
}
|
||||
|
||||
// Query with until filter
|
||||
metrics2, err := db.GetObserverMetrics("obs1", since, t2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metrics2) != 2 {
|
||||
t.Errorf("expected 2 metrics with until filter, got %d", len(metrics2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetricsSummary(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
|
||||
now := time.Now().UTC()
|
||||
t1 := now.Add(-2 * time.Hour).Format(time.RFC3339)
|
||||
t2 := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor, battery_mv) VALUES (?, ?, ?, ?)",
|
||||
"obs1", t1, -112.0, 3720)
|
||||
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
|
||||
"obs1", t2, -108.0)
|
||||
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
|
||||
"obs2", t1, -115.0)
|
||||
|
||||
since := now.Add(-24 * time.Hour).Format(time.RFC3339)
|
||||
summary, err := db.GetMetricsSummary(since)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summary) != 2 {
|
||||
t.Fatalf("expected 2 observers in summary, got %d", len(summary))
|
||||
}
|
||||
|
||||
// Results sorted by max_nf DESC
|
||||
// obs1 has max -108, obs2 has max -115
|
||||
if summary[0].ObserverID != "obs1" {
|
||||
t.Errorf("first observer should be obs1 (highest max NF), got %s", summary[0].ObserverID)
|
||||
}
|
||||
if summary[0].CurrentNF == nil || *summary[0].CurrentNF != -108.0 {
|
||||
t.Errorf("obs1 current NF = %v, want -108.0", summary[0].CurrentNF)
|
||||
}
|
||||
if summary[0].SampleCount != 2 {
|
||||
t.Errorf("obs1 sample count = %d, want 2", summary[0].SampleCount)
|
||||
}
|
||||
// Verify sparkline data is included
|
||||
if len(summary[0].Sparkline) != 2 {
|
||||
t.Errorf("obs1 sparkline length = %d, want 2", len(summary[0].Sparkline))
|
||||
}
|
||||
if len(summary[1].Sparkline) != 1 {
|
||||
t.Errorf("obs2 sparkline length = %d, want 1", len(summary[1].Sparkline))
|
||||
}
|
||||
// Sparkline should be ordered by timestamp ASC
|
||||
if summary[0].Sparkline[0] != nil && *summary[0].Sparkline[0] != -112.0 {
|
||||
t.Errorf("obs1 sparkline[0] = %v, want -112.0", *summary[0].Sparkline[0])
|
||||
}
|
||||
if summary[0].Sparkline[1] != nil && *summary[0].Sparkline[1] != -108.0 {
|
||||
t.Errorf("obs1 sparkline[1] = %v, want -108.0", *summary[0].Sparkline[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserverMetricsAPIEndpoints(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
seedTestData(t, db)
|
||||
|
||||
now := time.Now().UTC()
|
||||
t1 := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
|
||||
"obs1", t1, -112.0)
|
||||
|
||||
// Query directly to verify
|
||||
metrics, err := db.GetObserverMetrics("obs1", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metrics) != 1 {
|
||||
t.Errorf("expected 1 metric, got %d", len(metrics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWindowDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want time.Duration
|
||||
err bool
|
||||
}{
|
||||
{"1h", time.Hour, false},
|
||||
{"24h", 24 * time.Hour, false},
|
||||
{"3d", 3 * 24 * time.Hour, false},
|
||||
{"30d", 30 * 24 * time.Hour, false},
|
||||
{"invalid", 0, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := parseWindowDuration(tc.input)
|
||||
if tc.err && err == nil {
|
||||
t.Errorf("parseWindowDuration(%q) expected error", tc.input)
|
||||
}
|
||||
if !tc.err && got != tc.want {
|
||||
t.Errorf("parseWindowDuration(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,31 @@ func main() {
|
||||
log.Printf("[prune] auto-prune enabled: packets older than %d days will be removed daily", days)
|
||||
}
|
||||
|
||||
// Auto-prune old metrics
|
||||
var stopMetricsPrune func()
|
||||
{
|
||||
metricsDays := cfg.MetricsRetentionDays()
|
||||
metricsPruneTicker := time.NewTicker(24 * time.Hour)
|
||||
metricsPruneDone := make(chan struct{})
|
||||
stopMetricsPrune = func() {
|
||||
metricsPruneTicker.Stop()
|
||||
close(metricsPruneDone)
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(2 * time.Minute) // stagger after packet prune
|
||||
database.PruneOldMetrics(metricsDays)
|
||||
for {
|
||||
select {
|
||||
case <-metricsPruneTicker.C:
|
||||
database.PruneOldMetrics(metricsDays)
|
||||
case <-metricsPruneDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[metrics-prune] auto-prune enabled: metrics older than %d days", metricsDays)
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
httpServer := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
@@ -278,6 +303,9 @@ func main() {
|
||||
if stopPrune != nil {
|
||||
stopPrune()
|
||||
}
|
||||
if stopMetricsPrune != nil {
|
||||
stopMetricsPrune()
|
||||
}
|
||||
|
||||
// 2. Gracefully drain HTTP connections (up to 15s)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
|
||||
@@ -154,6 +154,8 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
|
||||
r.HandleFunc("/api/resolve-hops", s.handleResolveHops).Methods("GET")
|
||||
r.HandleFunc("/api/channels/{hash}/messages", s.handleChannelMessages).Methods("GET")
|
||||
r.HandleFunc("/api/channels", s.handleChannels).Methods("GET")
|
||||
r.HandleFunc("/api/observers/metrics/summary", s.handleMetricsSummary).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}/metrics", s.handleObserverMetrics).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}/analytics", s.handleObserverAnalytics).Methods("GET")
|
||||
r.HandleFunc("/api/observers/{id}", s.handleObserverDetail).Methods("GET")
|
||||
r.HandleFunc("/api/observers", s.handleObservers).Methods("GET")
|
||||
@@ -2153,6 +2155,80 @@ func nullFloatVal(n sql.NullFloat64) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Server) handleObserverMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
since := r.URL.Query().Get("since")
|
||||
until := r.URL.Query().Get("until")
|
||||
|
||||
// Default to last 24h if no since provided
|
||||
if since == "" {
|
||||
since = time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
metrics, err := s.db.GetObserverMetrics(id, since, until)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if metrics == nil {
|
||||
metrics = []MetricsSample{}
|
||||
}
|
||||
|
||||
// Get observer name
|
||||
obs, _ := s.db.GetObserverByID(id)
|
||||
var name *string
|
||||
if obs != nil {
|
||||
name = obs.Name
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"observer_id": id,
|
||||
"observer_name": name,
|
||||
"metrics": metrics,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMetricsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
window := r.URL.Query().Get("window")
|
||||
if window == "" {
|
||||
window = "24h"
|
||||
}
|
||||
|
||||
// Parse window duration
|
||||
dur, err := parseWindowDuration(window)
|
||||
if err != nil {
|
||||
writeError(w, 400, "invalid window: "+window)
|
||||
return
|
||||
}
|
||||
|
||||
since := time.Now().UTC().Add(-dur).Format(time.RFC3339)
|
||||
summary, err := s.db.GetMetricsSummary(since)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if summary == nil {
|
||||
summary = []MetricsSummaryRow{}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"observers": summary,
|
||||
})
|
||||
}
|
||||
|
||||
// parseWindowDuration parses strings like "24h", "3d", "7d", "30d".
|
||||
func parseWindowDuration(window string) (time.Duration, error) {
|
||||
if strings.HasSuffix(window, "d") {
|
||||
daysStr := strings.TrimSuffix(window, "d")
|
||||
days, err := strconv.Atoi(daysStr)
|
||||
if err != nil || days <= 0 {
|
||||
return 0, fmt.Errorf("invalid days: %s", daysStr)
|
||||
}
|
||||
return time.Duration(days) * 24 * time.Hour, nil
|
||||
}
|
||||
return time.ParseDuration(window)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPrune(w http.ResponseWriter, r *http.Request) {
|
||||
days := 0
|
||||
if d := r.URL.Query().Get("days"); d != "" {
|
||||
|
||||
@@ -3583,3 +3583,41 @@ func TestPathHopIndexIncrementalUpdate(t *testing.T) {
|
||||
t.Error("expected resolved pubkey key to be deleted after removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsAPIEndpoints(t *testing.T) {
|
||||
srv, router := setupTestServer(t)
|
||||
|
||||
now := time.Now().UTC()
|
||||
t1 := now.Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
|
||||
srv.db.conn.Exec("INSERT INTO observer_metrics (observer_id, timestamp, noise_floor) VALUES (?, ?, ?)",
|
||||
"obs1", t1, -112.0)
|
||||
|
||||
// Test /api/observers/obs1/metrics
|
||||
req := httptest.NewRequest("GET", "/api/observers/obs1/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("GET /api/observers/obs1/metrics = %d, want 200", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
metrics, ok := resp["metrics"].([]interface{})
|
||||
if !ok || len(metrics) != 1 {
|
||||
t.Errorf("expected 1 metric in response, got %v", resp["metrics"])
|
||||
}
|
||||
|
||||
// Test /api/observers/metrics/summary
|
||||
req2 := httptest.NewRequest("GET", "/api/observers/metrics/summary?window=24h", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
if w2.Code != 200 {
|
||||
t.Fatalf("GET /api/observers/metrics/summary = %d, want 200", w2.Code)
|
||||
}
|
||||
var resp2 map[string]interface{}
|
||||
json.Unmarshal(w2.Body.Bytes(), &resp2)
|
||||
observers, ok := resp2["observers"].([]interface{})
|
||||
if !ok || len(observers) != 1 {
|
||||
t.Errorf("expected 1 observer in summary, got %v", resp2["observers"])
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@
|
||||
<button class="tab-btn" data-tab="nodes">Nodes</button>
|
||||
<button class="tab-btn" data-tab="distance">Distance</button>
|
||||
<button class="tab-btn" data-tab="neighbor-graph">Neighbor Graph</button>
|
||||
<button class="tab-btn" data-tab="rf-health">RF Health</button>
|
||||
<button class="tab-btn" data-tab="prefix-tool">Prefix Tool</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,6 +175,7 @@
|
||||
case 'nodes': await renderNodesTab(el); break;
|
||||
case 'distance': await renderDistanceTab(el); break;
|
||||
case 'neighbor-graph': await renderNeighborGraphTab(el); break;
|
||||
case 'rf-health': await renderRFHealthTab(el); break;
|
||||
case 'prefix-tool': await renderPrefixTool(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
@@ -2595,5 +2597,350 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== RF HEALTH =====================
|
||||
|
||||
let _rfHealthState = { range: '24h', selectedObserver: null, customFrom: '', customTo: '' };
|
||||
|
||||
function rfHealthTimeRangeToParams(range, customFrom, customTo) {
|
||||
const now = new Date();
|
||||
let since, until;
|
||||
if (range === 'custom' && customFrom) {
|
||||
since = new Date(customFrom).toISOString();
|
||||
until = customTo ? new Date(customTo).toISOString() : now.toISOString();
|
||||
} else {
|
||||
const durations = { '1h': 1, '3h': 3, '6h': 6, '12h': 12, '24h': 24, '3d': 72, '7d': 168, '30d': 720 };
|
||||
const hours = durations[range] || 24;
|
||||
since = new Date(now.getTime() - hours * 3600000).toISOString();
|
||||
until = now.toISOString();
|
||||
}
|
||||
return { since, until };
|
||||
}
|
||||
|
||||
function rfHealthUpdateHash() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tab', 'rf-health');
|
||||
if (_rfHealthState.range !== '24h') params.set('range', _rfHealthState.range);
|
||||
if (_rfHealthState.selectedObserver) params.set('observer', _rfHealthState.selectedObserver);
|
||||
if (_rfHealthState.range === 'custom') {
|
||||
if (_rfHealthState.customFrom) params.set('from', _rfHealthState.customFrom);
|
||||
if (_rfHealthState.customTo) params.set('to', _rfHealthState.customTo);
|
||||
}
|
||||
history.replaceState(null, '', '#/analytics?' + params.toString());
|
||||
}
|
||||
|
||||
async function renderRFHealthTab(el) {
|
||||
// Restore state from URL
|
||||
const hashParams = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||
if (hashParams.get('range')) _rfHealthState.range = hashParams.get('range');
|
||||
if (hashParams.get('observer')) _rfHealthState.selectedObserver = hashParams.get('observer');
|
||||
if (hashParams.get('from')) { _rfHealthState.customFrom = hashParams.get('from'); _rfHealthState.range = 'custom'; }
|
||||
if (hashParams.get('to')) { _rfHealthState.customTo = hashParams.get('to'); _rfHealthState.range = 'custom'; }
|
||||
|
||||
const ranges = ['1h','3h','6h','12h','24h','3d','7d','30d'];
|
||||
const rangeButtons = ranges.map(r =>
|
||||
`<button class="rf-range-btn${_rfHealthState.range === r ? ' active' : ''}" data-range="${r}">${r}</button>`
|
||||
).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="rf-health-container">
|
||||
<div class="rf-time-selector">
|
||||
${rangeButtons}
|
||||
<button class="rf-range-btn${_rfHealthState.range === 'custom' ? ' active' : ''}" data-range="custom">Custom</button>
|
||||
<span class="rf-custom-inputs" style="display:${_rfHealthState.range === 'custom' ? 'inline' : 'none'}">
|
||||
<input type="datetime-local" class="rf-datetime" id="rfFrom" value="${_rfHealthState.customFrom}">
|
||||
<span>→</span>
|
||||
<input type="datetime-local" class="rf-datetime" id="rfTo" value="${_rfHealthState.customTo}">
|
||||
<button class="rf-range-btn" id="rfCustomApply">Apply</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="rfHealthGrid" class="rf-health-grid">
|
||||
<div class="text-muted" style="padding:20px">Loading RF metrics…</div>
|
||||
</div>
|
||||
<div id="rfHealthDetail" class="rf-health-detail" style="display:none"></div>
|
||||
</div>`;
|
||||
|
||||
// Range button handlers
|
||||
el.querySelectorAll('.rf-range-btn[data-range]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const range = btn.dataset.range;
|
||||
_rfHealthState.range = range;
|
||||
el.querySelectorAll('.rf-range-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const customInputs = el.querySelector('.rf-custom-inputs');
|
||||
if (customInputs) customInputs.style.display = range === 'custom' ? 'inline' : 'none';
|
||||
if (range !== 'custom') {
|
||||
rfHealthUpdateHash();
|
||||
loadRFHealthData(el);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const applyBtn = document.getElementById('rfCustomApply');
|
||||
if (applyBtn) {
|
||||
applyBtn.addEventListener('click', () => {
|
||||
_rfHealthState.customFrom = document.getElementById('rfFrom').value;
|
||||
_rfHealthState.customTo = document.getElementById('rfTo').value;
|
||||
rfHealthUpdateHash();
|
||||
loadRFHealthData(el);
|
||||
});
|
||||
}
|
||||
|
||||
await loadRFHealthData(el);
|
||||
}
|
||||
|
||||
async function loadRFHealthData(el) {
|
||||
const grid = document.getElementById('rfHealthGrid');
|
||||
const detail = document.getElementById('rfHealthDetail');
|
||||
|
||||
try {
|
||||
// Compute window string for summary endpoint
|
||||
const windowMap = { '1h':'1h', '3h':'3h', '6h':'6h', '12h':'12h', '24h':'24h', '3d':'3d', '7d':'7d', '30d':'30d' };
|
||||
const window = windowMap[_rfHealthState.range] || '24h';
|
||||
const summaryData = await api('/observers/metrics/summary?window=' + window);
|
||||
const observers = summaryData.observers || [];
|
||||
|
||||
if (!observers.length) {
|
||||
grid.innerHTML = '<div class="text-muted" style="padding:20px">No RF metrics data available yet. Metrics are collected from observer status messages every ~5 minutes.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render small multiples grid
|
||||
grid.innerHTML = observers.map(obs => {
|
||||
const nf = obs.current_noise_floor != null ? obs.current_noise_floor.toFixed(1) : '—';
|
||||
const avgNf = obs.avg_noise_floor_24h != null ? obs.avg_noise_floor_24h.toFixed(1) : '—';
|
||||
const maxNf = obs.max_noise_floor_24h != null ? obs.max_noise_floor_24h.toFixed(1) : '—';
|
||||
const batt = obs.battery_mv != null ? (obs.battery_mv / 1000).toFixed(2) + 'V' : '';
|
||||
const name = obs.observer_name || obs.observer_id.substring(0, 8);
|
||||
const isSelected = _rfHealthState.selectedObserver === obs.observer_id;
|
||||
|
||||
// NF status coloring
|
||||
let nfClass = '';
|
||||
if (obs.current_noise_floor != null) {
|
||||
if (obs.current_noise_floor >= -85) nfClass = 'rf-nf-critical';
|
||||
else if (obs.current_noise_floor >= -100) nfClass = 'rf-nf-warning';
|
||||
}
|
||||
|
||||
return `<div class="rf-cell${isSelected ? ' rf-cell-selected' : ''}" data-observer="${obs.observer_id}" tabindex="0" role="button" aria-label="Observer ${name}, noise floor ${nf} dBm">
|
||||
<div class="rf-cell-header">
|
||||
<span class="rf-cell-name">${esc(name)}</span>
|
||||
<span class="rf-cell-nf ${nfClass}">${nf} dBm</span>
|
||||
${batt ? `<span class="rf-cell-batt">${batt}</span>` : ''}
|
||||
</div>
|
||||
<div class="rf-cell-sparkline" id="rf-spark-${obs.observer_id}"></div>
|
||||
<div class="rf-cell-stats">
|
||||
<span>avg: ${avgNf}</span>
|
||||
<span>max: ${maxNf}</span>
|
||||
<span>${obs.sample_count} samples</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Click handler for cells
|
||||
grid.querySelectorAll('.rf-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
const obsId = cell.dataset.observer;
|
||||
grid.querySelectorAll('.rf-cell').forEach(c => c.classList.remove('rf-cell-selected'));
|
||||
cell.classList.add('rf-cell-selected');
|
||||
_rfHealthState.selectedObserver = obsId;
|
||||
rfHealthUpdateHash();
|
||||
loadRFHealthDetail(obsId, detail);
|
||||
});
|
||||
cell.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); cell.click(); }
|
||||
});
|
||||
});
|
||||
|
||||
// Render sparklines from summary data (no extra API calls)
|
||||
for (const obs of observers) {
|
||||
const nfValues = (obs.sparkline || []).filter(v => v != null);
|
||||
const container = document.getElementById(`rf-spark-${obs.observer_id}`);
|
||||
if (container && nfValues.length > 1) {
|
||||
container.innerHTML = rfNFSparkline(nfValues, 140, 24);
|
||||
} else if (container) {
|
||||
container.innerHTML = '<span class="text-muted" style="font-size:10px">insufficient data</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-expand selected observer from URL
|
||||
if (_rfHealthState.selectedObserver) {
|
||||
const selectedCell = grid.querySelector(`[data-observer="${_rfHealthState.selectedObserver}"]`);
|
||||
if (selectedCell) {
|
||||
selectedCell.classList.add('rf-cell-selected');
|
||||
loadRFHealthDetail(_rfHealthState.selectedObserver, detail);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="text-muted" style="padding:20px">Failed to load RF health data: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRFSparkline(observerId) {
|
||||
const { since, until } = rfHealthTimeRangeToParams(_rfHealthState.range, _rfHealthState.customFrom, _rfHealthState.customTo);
|
||||
try {
|
||||
const data = await api(`/observers/${observerId}/metrics?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`);
|
||||
const metrics = data.metrics || [];
|
||||
const nfValues = metrics.map(m => m.noise_floor).filter(v => v != null);
|
||||
const container = document.getElementById(`rf-spark-${observerId}`);
|
||||
if (container && nfValues.length > 1) {
|
||||
container.innerHTML = rfNFSparkline(nfValues, 140, 24);
|
||||
} else if (container) {
|
||||
container.innerHTML = '<span class="text-muted" style="font-size:10px">insufficient data</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal — sparkline just won't render
|
||||
}
|
||||
}
|
||||
|
||||
function rfNFSparkline(data, w, h) {
|
||||
if (!data.length) return '';
|
||||
// For noise floor, invert: more negative = better = lower on chart
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const range = max - min || 1;
|
||||
const pts = data.map((v, i) => {
|
||||
const x = (i / Math.max(data.length - 1, 1)) * w;
|
||||
// Higher dBm (worse) = higher on chart
|
||||
const y = h - 2 - ((v - min) / range) * (h - 4);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
|
||||
// Reference lines
|
||||
let refs = '';
|
||||
if (min <= -100 && max >= -100) {
|
||||
const y100 = h - 2 - ((-100 - min) / range) * (h - 4);
|
||||
refs += `<line x1="0" y1="${y100.toFixed(1)}" x2="${w}" y2="${y100.toFixed(1)}" stroke="var(--text-muted)" stroke-width="0.5" stroke-dasharray="2"/>`;
|
||||
}
|
||||
|
||||
return `<svg viewBox="0 0 ${w} ${h}" style="width:${w}px;height:${h}px" role="img" aria-label="Noise floor sparkline"><title>Noise floor trend</title>${refs}<polyline points="${pts}" fill="none" stroke="var(--accent)" stroke-width="1.5"/></svg>`;
|
||||
}
|
||||
|
||||
async function loadRFHealthDetail(observerId, container) {
|
||||
container.style.display = 'block';
|
||||
container.innerHTML = '<div class="text-muted" style="padding:10px">Loading detail…</div>';
|
||||
|
||||
const { since, until } = rfHealthTimeRangeToParams(_rfHealthState.range, _rfHealthState.customFrom, _rfHealthState.customTo);
|
||||
try {
|
||||
const data = await api(`/observers/${observerId}/metrics?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`);
|
||||
const metrics = data.metrics || [];
|
||||
const name = data.observer_name || observerId.substring(0, 8);
|
||||
|
||||
if (!metrics.length) {
|
||||
container.innerHTML = `<div class="text-muted" style="padding:10px">No metrics data for ${esc(name)} in selected time range.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract noise floor data
|
||||
const nfData = metrics.map(m => ({ t: m.timestamp, v: m.noise_floor })).filter(d => d.v != null);
|
||||
|
||||
// Current values
|
||||
const latest = metrics[metrics.length - 1];
|
||||
const nfValues = metrics.map(m => m.noise_floor).filter(v => v != null);
|
||||
const avgNf = nfValues.length ? (nfValues.reduce((a,b) => a+b, 0) / nfValues.length).toFixed(1) : '—';
|
||||
const minNf = nfValues.length ? Math.min(...nfValues).toFixed(1) : '—';
|
||||
const maxNf = nfValues.length ? Math.max(...nfValues).toFixed(1) : '—';
|
||||
const curNf = latest.noise_floor != null ? latest.noise_floor.toFixed(1) : '—';
|
||||
const curBatt = latest.battery_mv != null ? (latest.battery_mv / 1000).toFixed(2) + 'V' : '—';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="rf-detail-header">
|
||||
<h3>${esc(name)}</h3>
|
||||
<button class="rf-detail-close" aria-label="Close detail" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="rf-detail-chart" id="rfDetailChart"></div>
|
||||
<div class="rf-detail-summary">
|
||||
NF: ${curNf} dBm | avg: ${avgNf} | min: ${minNf} | max: ${maxNf} | Batt: ${curBatt}
|
||||
</div>`;
|
||||
|
||||
// Close button
|
||||
container.querySelector('.rf-detail-close').addEventListener('click', () => {
|
||||
container.style.display = 'none';
|
||||
_rfHealthState.selectedObserver = null;
|
||||
rfHealthUpdateHash();
|
||||
document.querySelectorAll('.rf-cell').forEach(c => c.classList.remove('rf-cell-selected'));
|
||||
});
|
||||
|
||||
// Render noise floor line chart
|
||||
const chartEl = document.getElementById('rfDetailChart');
|
||||
if (nfData.length > 1) {
|
||||
chartEl.innerHTML = rfNFLineChart(nfData, chartEl.clientWidth || 700, 200);
|
||||
} else {
|
||||
chartEl.innerHTML = '<span class="text-muted">Not enough data points for chart</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="text-muted" style="padding:10px">Failed to load detail: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function rfNFLineChart(data, w, h) {
|
||||
const pad = { top: 20, right: 40, bottom: 30, left: 55 };
|
||||
const cw = w - pad.left - pad.right;
|
||||
const ch = h - pad.top - pad.bottom;
|
||||
|
||||
const values = data.map(d => d.v);
|
||||
const times = data.map(d => new Date(d.t).getTime());
|
||||
const minT = Math.min(...times);
|
||||
const maxT = Math.max(...times);
|
||||
const minV = Math.min(...values);
|
||||
const maxV = Math.max(...values);
|
||||
const rangeV = maxV - minV || 1;
|
||||
const rangeT = maxT - minT || 1;
|
||||
|
||||
// Scale functions
|
||||
const sx = t => pad.left + ((t - minT) / rangeT) * cw;
|
||||
const sy = v => pad.top + ch - ((v - minV) / rangeV) * ch; // higher dBm (worse) = higher on chart (lower y)
|
||||
|
||||
// Data line
|
||||
const pts = data.map(d => `${sx(new Date(d.t).getTime()).toFixed(1)},${sy(d.v).toFixed(1)}`).join(' ');
|
||||
|
||||
let svg = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;max-height:${h}px" role="img" aria-label="Noise floor line chart"><title>Noise floor over time</title>`;
|
||||
|
||||
// Reference lines
|
||||
const refLines = [-100, -85];
|
||||
const refLabels = ['-100 warning', '-85 critical'];
|
||||
refLines.forEach((ref, i) => {
|
||||
if (ref >= minV && ref <= maxV) {
|
||||
const y = sy(ref);
|
||||
svg += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="var(--text-muted)" stroke-width="0.5" stroke-dasharray="4,2"/>`;
|
||||
svg += `<text x="${w - pad.right + 2}" y="${(y + 3).toFixed(1)}" font-size="9" fill="var(--text-muted)">${refLabels[i]}</text>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Y-axis labels
|
||||
const yTicks = 5;
|
||||
for (let i = 0; i <= yTicks; i++) {
|
||||
const v = minV + (rangeV * i / yTicks);
|
||||
const y = sy(v);
|
||||
svg += `<text x="${pad.left - 4}" y="${(y + 3).toFixed(1)}" text-anchor="end" font-size="9" fill="var(--text-muted)">${v.toFixed(0)}</text>`;
|
||||
svg += `<line x1="${pad.left}" y1="${y.toFixed(1)}" x2="${w - pad.right}" y2="${y.toFixed(1)}" stroke="var(--border)" stroke-width="0.3"/>`;
|
||||
}
|
||||
|
||||
// X-axis labels
|
||||
const xTicks = Math.min(6, data.length);
|
||||
for (let i = 0; i < xTicks; i++) {
|
||||
const idx = Math.floor(i * (data.length - 1) / Math.max(xTicks - 1, 1));
|
||||
const t = new Date(data[idx].t);
|
||||
const x = sx(t.getTime());
|
||||
const label = t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
svg += `<text x="${x.toFixed(1)}" y="${h - 5}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${label}</text>`;
|
||||
}
|
||||
|
||||
// Data polyline
|
||||
svg += `<polyline points="${pts}" fill="none" stroke="var(--accent)" stroke-width="1.5"/>`;
|
||||
|
||||
// Direct labels: min and max points
|
||||
const maxIdx = values.indexOf(maxV);
|
||||
const minIdx = values.indexOf(minV);
|
||||
svg += `<circle cx="${sx(times[maxIdx]).toFixed(1)}" cy="${sy(maxV).toFixed(1)}" r="3" fill="var(--danger, red)"/>`;
|
||||
svg += `<text x="${sx(times[maxIdx]).toFixed(1)}" y="${(sy(maxV) - 6).toFixed(1)}" text-anchor="middle" font-size="9" fill="var(--danger, red)">${maxV.toFixed(1)}</text>`;
|
||||
svg += `<circle cx="${sx(times[minIdx]).toFixed(1)}" cy="${sy(minV).toFixed(1)}" r="3" fill="var(--success, green)"/>`;
|
||||
svg += `<text x="${sx(times[minIdx]).toFixed(1)}" y="${(sy(minV) + 14).toFixed(1)}" text-anchor="middle" font-size="9" fill="var(--success, green)">${minV.toFixed(1)}</text>`;
|
||||
|
||||
// Y-axis label
|
||||
svg += `<text x="12" y="${(h / 2)}" text-anchor="middle" font-size="10" fill="var(--text-muted)" transform="rotate(-90,12,${h/2})">dBm</text>`;
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
})();
|
||||
@@ -1958,3 +1958,66 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
#ngCanvas:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ===================== RF Health Dashboard ===================== */
|
||||
.rf-health-container { padding: 0; }
|
||||
.rf-time-selector {
|
||||
display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
|
||||
margin-bottom: 16px; padding: 8px 0;
|
||||
}
|
||||
.rf-range-btn {
|
||||
padding: 4px 10px; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--bg-secondary, var(--card-bg, #1e1e1e)); color: var(--text-primary, #e0e0e0);
|
||||
cursor: pointer; font-size: 12px; transition: background 0.15s;
|
||||
}
|
||||
.rf-range-btn:hover { background: var(--bg-hover, #333); }
|
||||
.rf-range-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.rf-custom-inputs { display: inline-flex; gap: 4px; align-items: center; margin-left: 8px; }
|
||||
.rf-datetime {
|
||||
padding: 3px 6px; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--bg-secondary, var(--card-bg)); color: var(--text-primary); font-size: 12px;
|
||||
}
|
||||
|
||||
.rf-health-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.rf-cell {
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px;
|
||||
cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
||||
background: var(--bg-secondary, var(--card-bg, #1e1e1e));
|
||||
}
|
||||
.rf-cell:hover { border-color: var(--accent); }
|
||||
.rf-cell:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.rf-cell-selected { border-color: var(--accent); background: var(--bg-hover, rgba(96,165,250,0.08)); }
|
||||
|
||||
.rf-cell-header { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; margin-bottom: 4px; }
|
||||
.rf-cell-name { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; }
|
||||
.rf-cell-nf { font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.rf-cell-batt { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
|
||||
.rf-nf-warning { color: var(--status-yellow, #f59e0b); }
|
||||
.rf-nf-critical { color: var(--status-red, #ef4444); }
|
||||
|
||||
.rf-cell-sparkline { height: 24px; margin: 2px 0; overflow: hidden; }
|
||||
.rf-cell-stats { display: flex; gap: 8px; font-size: 10px; color: var(--text-muted); }
|
||||
|
||||
/* Detail pane */
|
||||
.rf-health-detail {
|
||||
margin-top: 16px; border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 12px 16px; background: var(--bg-secondary, var(--card-bg));
|
||||
}
|
||||
.rf-detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.rf-detail-header h3 { margin: 0; font-size: 16px; }
|
||||
.rf-detail-close {
|
||||
background: none; border: none; color: var(--text-muted); cursor: pointer;
|
||||
font-size: 18px; padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
.rf-detail-close:hover { background: var(--bg-hover); }
|
||||
.rf-detail-chart { margin: 8px 0; overflow-x: auto; }
|
||||
.rf-detail-summary { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rf-health-grid { grid-template-columns: 1fr; }
|
||||
.rf-time-selector { gap: 3px; }
|
||||
.rf-custom-inputs { margin-left: 0; margin-top: 4px; flex-wrap: wrap; }
|
||||
}
|
||||
Reference in new issue
Block a user