diff --git a/cmd/ingestor/config.go b/cmd/ingestor/config.go index c553cbdf..4cda02aa 100644 --- a/cmd/ingestor/config.go +++ b/cmd/ingestor/config.go @@ -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. diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index c582a369..028290a8 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -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() { diff --git a/cmd/ingestor/db_test.go b/cmd/ingestor/db_test.go index dc3f76b8..b9ef3f6d 100644 --- a/cmd/ingestor/db_test.go +++ b/cmd/ingestor/db_test.go @@ -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) + } +} diff --git a/cmd/ingestor/main.go b/cmd/ingestor/main.go index 238b436d..9fd569db 100644 --- a/cmd/ingestor/main.go +++ b/cmd/ingestor/main.go @@ -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 diff --git a/cmd/server/config.go b/cmd/server/config.go index 0aee4cb3..442a996f 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -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 } diff --git a/cmd/server/db.go b/cmd/server/db.go index 6c253d6f..8719f2d2 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -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 +} diff --git a/cmd/server/db_test.go b/cmd/server/db_test.go index 35db9c70..b56abd5b 100644 --- a/cmd/server/db_test.go +++ b/cmd/server/db_test.go @@ -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) + } + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index a498b697..914a9185 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 88f86234..4ef2de8a 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -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 != "" { diff --git a/cmd/server/routes_test.go b/cmd/server/routes_test.go index 4b0052d9..75e669fe 100644 --- a/cmd/server/routes_test.go +++ b/cmd/server/routes_test.go @@ -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"]) + } +} diff --git a/public/analytics.js b/public/analytics.js index 934f9edf..e5f2eec9 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -86,6 +86,7 @@ + @@ -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 => + `` + ).join(''); + + el.innerHTML = ` +
+
+ ${rangeButtons} + + + + → + + + +
+
+
Loading RF metrics…
+
+ +
`; + + // 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 = '
No RF metrics data available yet. Metrics are collected from observer status messages every ~5 minutes.
'; + 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 `
+
+ ${esc(name)} + ${nf} dBm + ${batt ? `${batt}` : ''} +
+
+
+ avg: ${avgNf} + max: ${maxNf} + ${obs.sample_count} samples +
+
`; + }).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 = 'insufficient data'; + } + } + + // 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 = `
Failed to load RF health data: ${esc(e.message)}
`; + } + } + + 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 = 'insufficient data'; + } + } 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 += ``; + } + + return `Noise floor trend${refs}`; + } + + async function loadRFHealthDetail(observerId, container) { + container.style.display = 'block'; + container.innerHTML = '
Loading detail…
'; + + 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 = `
No metrics data for ${esc(name)} in selected time range.
`; + 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 = ` +
+

${esc(name)}

+ +
+
+
+ NF: ${curNf} dBm | avg: ${avgNf} | min: ${minNf} | max: ${maxNf} | Batt: ${curBatt} +
`; + + // 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 = 'Not enough data points for chart'; + } + } catch (e) { + container.innerHTML = `
Failed to load detail: ${esc(e.message)}
`; + } + } + + 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 = `Noise floor over time`; + + // 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 += ``; + svg += `${refLabels[i]}`; + } + }); + + // Y-axis labels + const yTicks = 5; + for (let i = 0; i <= yTicks; i++) { + const v = minV + (rangeV * i / yTicks); + const y = sy(v); + svg += `${v.toFixed(0)}`; + svg += ``; + } + + // 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 += `${label}`; + } + + // Data polyline + svg += ``; + + // Direct labels: min and max points + const maxIdx = values.indexOf(maxV); + const minIdx = values.indexOf(minV); + svg += ``; + svg += `${maxV.toFixed(1)}`; + svg += ``; + svg += `${minV.toFixed(1)}`; + + // Y-axis label + svg += `dBm`; + + svg += ''; + return svg; + } + registerPage('analytics', { init, destroy }); })(); diff --git a/public/style.css b/public/style.css index d66752cf..a5ea843b 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } +}