From 5036bee597850c3674883a9746f662600c10ccad Mon Sep 17 00:00:00 2001 From: masseselsev Date: Sat, 19 Sep 2026 10:48:59 +0000 Subject: [PATCH 1/2] feat: bootstrap the telemetry bar from history medians on cold connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hub with no cached tick โ€” server restart, or the router briefly down โ€” left the telemetry bar empty until the first successful poll. HandleWS now falls back to a bootstrap-tagged telemetry_tick built from the 15-minute metric buckets (medians of CPU/RAM/temperature, and of the summed WAN rates over the monitored interfaces; buckets with incomplete interface coverage are skipped by a HAVING guard) plus live user/device counts read straight from the database. Nothing is invented: absent stays absent, and a router with no history gets no frame at all. The UI seeds the sparklines from the medians as a flat anchor line and dims the measurement tiles until a live tick lands; real ticks then append over the anchor, so the bar converges from the median toward the live curve instead of snapping from empty to busy. The frame cache in the hub keeps a per-router bootstrap for one minute so a refresh storm against a down router costs one aggregate query, and a live tick takes precedence over the cached bootstrap on every subsequent connect. --- README.md | 6 + backend-go/internal/api/router.go | 3 + .../internal/api/telemetry_bootstrap.go | 300 ++++++++++++++++++ .../internal/api/telemetry_bootstrap_test.go | 285 +++++++++++++++++ backend-go/internal/api/ws.go | 15 + frontend/src/components/TelemetryBar.jsx | 33 +- frontend/src/components/TelemetryBar.test.jsx | 35 ++ frontend/src/index.css | 10 + 8 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 backend-go/internal/api/telemetry_bootstrap.go create mode 100644 backend-go/internal/api/telemetry_bootstrap_test.go diff --git a/README.md b/README.md index e882f5f..1e609ff 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,12 @@ * **๐Ÿ›ก๏ธ Multi-Router Management & Isolated Environments**: * Complete operational isolation: users, devices, queues, rollups, quotas, and timezone offsets exist strictly per-router. * Instant context switching in UI and WebSocket telemetry. + * The telemetry bar never opens empty: when a client connects before the first + live tick exists (cold server start, or a router that is briefly unreachable), + the hub replays a bootstrap frame โ€” 15-minute medians from the stored metric + buckets for CPU/RAM/traffic, plus live user and device counts โ€” tagged so the + UI can render the values subdued until a real tick replaces them. A router + with no history gets no frame at all: placeholders stay, which is honest. * Seamless hardware swap workflow (`Change Router`) with data retention choices (`keep` vs `reset_hardware`). * Soft archive vs permanent purge router lifecycles. * Stored credentials never cross the API read path: `GET /api/v1/system/settings` answers `********` for the Telegram bot token, and a settings form that posts that value straight back is understood to mean "unchanged" rather than overwriting the token. The settings form labels the field as hidden so eight bullets in a password box cannot be mistaken for a real credential. diff --git a/backend-go/internal/api/router.go b/backend-go/internal/api/router.go index adf3e09..12b8f13 100644 --- a/backend-go/internal/api/router.go +++ b/backend-go/internal/api/router.go @@ -39,6 +39,9 @@ func NewRouter(rc RouterConfig) http.Handler { // WebSocket Telemetry endpoint for frontend (/ws/telemetry) if rc.Hub != nil { + // Give the hub history access so a client that connects before the + // first live tick still gets the telemetry bar filled from buckets. + rc.Hub.AttachDatabase(rc.DB) r.With(CORSMiddleware).Get("/ws/telemetry", rc.Hub.HandleWS) } diff --git a/backend-go/internal/api/telemetry_bootstrap.go b/backend-go/internal/api/telemetry_bootstrap.go new file mode 100644 index 0000000..999ee8b --- /dev/null +++ b/backend-go/internal/api/telemetry_bootstrap.go @@ -0,0 +1,300 @@ +package api + +import ( + "database/sql" + "encoding/json" + "fmt" + "math" + "sort" + "strings" + "time" + + "github.com/masseselsev/mikroman/internal/db" +) + +// A client that connects before any live tick exists โ€” a cold server start, or +// a router that is currently unreachable โ€” used to see an empty telemetry bar +// until the first successful poll. The bootstrap frame closes that gap: a +// telemetry_tick assembled from stored history (15-minute medians) plus the +// database's live user/device counts, tagged so it can never be mistaken for +// a real-time reading. It is a placeholder that looks plausible, not a claim +// about the present: the first live tick replaces it within seconds, and a +// router with no history at all still gets nothing, which is the honest state. + +// bootstrapWindowBuckets is how many recent buckets the frame summarises. +// Buckets are 15 minutes, so 96 of them span the same horizon as the 24 h +// chart. The most recent rows are taken rather than a wall-clock window: a +// freshly installed router bootstraps from its handful of buckets instead of +// waiting for a full day of history to exist. +const bootstrapWindowBuckets = 96 + +// bootstrapTTL caps how long a generated frame is reused across connections. +// Cold clients arriving together (a refresh storm while the router is down) +// should not each re-run the median queries; a minute keeps the DB-backed +// counts credible between regenerations. +const bootstrapTTL = time.Minute + +type bootstrapEntry struct { + frame []byte + created time.Time +} + +// AttachDatabase gives the hub read access to persisted history, enabling the +// bootstrap frame for clients that connect before any live tick. Called once +// at startup; a hub without a database behaves exactly as before, which is +// what the hub's own unit tests rely on. +func (h *Hub) AttachDatabase(database *db.DB) { + h.mu.Lock() + defer h.mu.Unlock() + h.bootDB = database + h.bootCache = make(map[int]bootstrapEntry) +} + +// bootstrapFrame returns the frame for routerID (0 = default router, matching +// the WS query-parameter semantics), building it on demand and caching per +// router for bootstrapTTL. Returns nil when there is nothing to show: no +// database, no buckets, unknown router, or no WAN configured โ€” the client +// then simply waits for a live tick like before. +func (h *Hub) bootstrapFrame(routerID int) []byte { + h.mu.Lock() + database := h.bootDB + if database == nil { + h.mu.Unlock() + return nil + } + if entry, ok := h.bootCache[routerID]; ok && time.Since(entry.created) < bootstrapTTL { + h.mu.Unlock() + return entry.frame + } + h.mu.Unlock() + + frame := buildBootstrapFrame(database, routerID) + + h.mu.Lock() + if frame != nil { + h.bootCache[routerID] = bootstrapEntry{frame: frame, created: time.Now()} + } + h.mu.Unlock() + return frame +} + +// buildBootstrapFrame assembles the telemetry_tick JSON. Split out from the +// hub so it is testable directly against any database. +func buildBootstrapFrame(database *db.DB, routerID int) []byte { + targetID, ok := resolveBootstrapRouter(database, routerID) + if !ok { + return nil + } + + router := map[string]interface{}{ + "id": targetID, + "bootstrapped": true, + } + // Every measurement is optional and emitted only when a median exists: + // absent must never render as zero โ€” that is the one sin a bootstrap frame + // may not commit, and it is why each tile keeps its em-dash placeholder. + if cpu, memPct, freeMB, totalMB, temp, sysOK := bootstrapSystemReading(database, targetID); sysOK { + router["cpu_load"] = round1(cpu) + router["memory_usage_pct"] = round1(memPct) + router["free_memory_mb"] = round1(freeMB) + router["total_memory_mb"] = round1(totalMB) + if temp != nil { + router["temperature"] = round1(*temp) + } + } + if rx, tx, wanOK := bootstrapWanReading(database, targetID); wanOK { + router["wan_rx_bps"] = math.Round(rx) + router["wan_tx_bps"] = math.Round(tx) + } + if names, ok := bootstrapMonitoredNames(database, targetID); ok { + router["monitored_interfaces"] = names + } + // User/device counts come straight from the database, not from buckets: + // the rosters are as current as the last successful sync, and a median + // would understate them the day after a client was added. A read failure + // omits the fields; the tiles then show the placeholder rather than a + // confidently wrong "0". + users, usersErr := database.GetUsers(&targetID) + if usersErr == nil { + router["user_count"] = len(users) + if devices, err := database.GetDevices(&targetID); err == nil { + active := 0 + for _, d := range devices { + if d.IsActive { + active++ + } + } + router["client_device_count"] = len(devices) + router["active_clients"] = active + } + } + + // If not one measurement could be summarised, send nothing: a frame of + // only the bootstrapped flag would swap an honest placeholder for a fake + // "alive" bar. (user_count alone still counts as content โ€” a known roster + // is a known roster.) + if len(router) <= 2 { + return nil + } + + frame, err := json.Marshal(map[string]interface{}{ + "type": "telemetry_tick", + "timestamp": float64(time.Now().Unix()), + "router_id": targetID, + "bootstrap": true, + "router": router, + }) + if err != nil { + return nil + } + return frame +} + +// resolveBootstrapRouter maps the requested id (0 = default) onto a real +// router row. An explicit id that does not exist gets no frame: scoping +// failures must never leak another router's history. +func resolveBootstrapRouter(database *db.DB, routerID int) (int, bool) { + if routerID == 0 { + def, err := database.GetDefaultRouter() + if err != nil || def == nil { + return 0, false + } + return def.ID, true + } + routers, err := database.GetRouters() + if err != nil { + return 0, false + } + for _, r := range routers { + if r.ID == routerID { + return r.ID, true + } + } + return 0, false +} + +// bootstrapSystemReading returns median CPU %, memory usage %, free/total RAM +// in MB, and โ€” only if the window ever carried it โ€” median temperature over +// the router's recent buckets. Free RAM is derived per bucket from its stored +// used/total pair; a router whose footprint changed mid-window is approximated +// by the median, which is exactly what a placeholder is allowed to do. +func bootstrapSystemReading(database *db.DB, routerID int) (cpu, memPct, freeMB, totalMB float64, temp *float64, ok bool) { + rows, err := database.SqlDB.Query(` + SELECT cpu_load_avg, memory_usage_pct_avg, + memory_used_bytes_avg, memory_total_bytes_max, temperature_avg + FROM system_metric_buckets + WHERE router_id = ? + ORDER BY bucket_start DESC + LIMIT ?`, routerID, bootstrapWindowBuckets) + if err != nil { + return 0, 0, 0, 0, nil, false + } + defer rows.Close() + + var cpus, pcts, frees, totals []float64 + var temps []float64 + for rows.Next() { + var cpuAvg, pctAvg, usedAvg, totalMax float64 + var tempAvg sql.NullFloat64 + if err := rows.Scan(&cpuAvg, &pctAvg, &usedAvg, &totalMax, &tempAvg); err != nil { + continue + } + cpus = append(cpus, cpuAvg) + pcts = append(pcts, pctAvg) + totalMBv := totalMax / (1024 * 1024) + frees = append(frees, math.Max(totalMBv-usedAvg/(1024*1024), 0)) + totals = append(totals, totalMBv) + if tempAvg.Valid { + temps = append(temps, tempAvg.Float64) + } + } + if len(cpus) == 0 { + return 0, 0, 0, 0, nil, false + } + if len(temps) > 0 { + t := medianFloat(temps) + temp = &t + } + return medianFloat(cpus), medianFloat(pcts), medianFloat(frees), medianFloat(totals), temp, true +} + +// bootstrapWanReading returns median rx/tx of the SUM of the monitored +// interfaces per bucket โ€” the same quantity the live tick reports (WAN rates +// are summed across the selection), just summarised. A bucket where not every +// monitored interface contributed is dropped via HAVING: its partial sum +// describes a quieter router than this one, and letting it into the median +// would bias the placeholder down for reasons unrelated to traffic. +func bootstrapWanReading(database *db.DB, routerID int) (rx, tx float64, ok bool) { + names, ok := bootstrapMonitoredNames(database, routerID) + if !ok { + return 0, 0, false + } + args := make([]interface{}, 0, len(names)+3) + args = append(args, routerID) + placeholders := make([]string, 0, len(names)) + for _, name := range names { + placeholders = append(placeholders, "?") + args = append(args, name) + } + args = append(args, len(names), bootstrapWindowBuckets) + query := fmt.Sprintf(` + SELECT bucket_start, SUM(rx_rate_bps_sum), SUM(tx_rate_bps_sum) + FROM interface_metric_buckets + WHERE router_id = ? AND interface_name IN (%s) + GROUP BY bucket_start + HAVING COUNT(DISTINCT interface_name) = ? + ORDER BY bucket_start DESC + LIMIT ?`, strings.Join(placeholders, ", ")) + + rows, err := database.SqlDB.Query(query, args...) + if err != nil { + return 0, 0, false + } + defer rows.Close() + + var rxs, txs []float64 + for rows.Next() { + var start string + var rxSum, txSum float64 + if err := rows.Scan(&start, &rxSum, &txSum); err != nil { + continue + } + rxs = append(rxs, rxSum) + txs = append(txs, txSum) + } + if len(rxs) == 0 { + return 0, 0, false + } + return medianFloat(rxs), medianFloat(txs), true +} + +// bootstrapMonitoredNames mirrors the collector's read of the WAN selection: +// the per-router key first, the legacy global key as fallback. +func bootstrapMonitoredNames(database *db.DB, routerID int) ([]string, bool) { + val, err := database.GetSetting(fmt.Sprintf("monitored_interfaces_%d", routerID)) + if err != nil || val == "" { + val, err = database.GetSetting("monitored_interfaces_default") + } + if err != nil || val == "" { + return nil, false + } + var names []string + if err := json.Unmarshal([]byte(val), &names); err != nil || len(names) == 0 { + return nil, false + } + return names, true +} + +func round1(v float64) float64 { return math.Round(v*10) / 10 } + +// medianFloat returns the middle value of a sorted copy of values. Callers +// guard against empty input. +func medianFloat(values []float64) float64 { + v := append([]float64{}, values...) + sort.Float64s(v) + mid := len(v) / 2 + if len(v)%2 == 1 { + return v[mid] + } + return (v[mid-1] + v[mid]) / 2 +} diff --git a/backend-go/internal/api/telemetry_bootstrap_test.go b/backend-go/internal/api/telemetry_bootstrap_test.go new file mode 100644 index 0000000..e98e650 --- /dev/null +++ b/backend-go/internal/api/telemetry_bootstrap_test.go @@ -0,0 +1,285 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/masseselsev/mikroman/internal/crypto" + "github.com/masseselsev/mikroman/internal/db" +) + +// startHubTestServer serves hub.HandleWS on a local httptest server and +// returns the ws:// URL plus the shutdown func. +func startHubTestServer(t *testing.T, hub *Hub) (string, func()) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(hub.HandleWS)) + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + return wsURL, server.Close +} + +func dialWS(t *testing.T, wsURL string, routerID int) *websocket.Conn { + t.Helper() + conn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("%s?router_id=%d", wsURL, routerID), nil) + if err != nil { + t.Fatalf("ws dial: %v", err) + } + return conn +} + +func readWSJSON(t *testing.T, conn *websocket.Conn) map[string]interface{} { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + var msg map[string]interface{} + if err := conn.ReadJSON(&msg); err != nil { + t.Fatalf("ws read: %v", err) + } + return msg +} + +func openBootstrapDB(t *testing.T) *db.DB { + t.Helper() + fernet, err := crypto.NewFernet("cw_z4pYJ2-8_9V18R5v6R1XbJ9i9w9G1R1XbJ9i9w9E=") + if err != nil { + t.Fatalf("fernet: %v", err) + } + database, err := db.Open(filepath.Join(t.TempDir(), "boot.db"), fernet) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + return database +} + +func seedBootstrapHistory(t *testing.T, database *db.DB) { + t.Helper() + // One router with 15-minute buckets over a few hours. cpu_load alternates + // so the median is a stable, hand-checkable number; ether1+ether2 are the + // monitored pair, and one late bucket deliberately misses ether2 โ€” it must + // be dropped by the coverage HAVING, not dilute the WAN median. + if _, err := database.SqlDB.Exec(` + INSERT INTO routers (id, name, host, is_default, is_active) + VALUES (1, 'BootRouter', '127.0.0.1', 1, 1)`); err != nil { + t.Fatalf("seed router: %v", err) + } + sys := []struct { + start string + cpu float64 + pct float64 + temp interface{} + }{ + {"2026-09-19 08:00:00", 10, 40, 44.0}, + {"2026-09-19 08:15:00", 20, 50, 46.0}, + {"2026-09-19 08:30:00", 30, 60, nil}, + {"2026-09-19 08:45:00", 40, 70, 50.0}, + {"2026-09-19 09:00:00", 50, 80, 52.0}, + } + for _, s := range sys { + if _, err := database.SqlDB.Exec(` + INSERT INTO system_metric_buckets (router_id, bucket_start, samples, + cpu_load_avg, cpu_load_max, memory_usage_pct_avg, memory_used_bytes_avg, + memory_total_bytes_max, temperature_avg, temperature_max, temperature_nonnull_samples) + VALUES (1, ?, 60, ?, ?, ?, ?, 1073741824, ?, ?, ?)`, + s.start, s.cpu, s.cpu, s.pct, s.pct*10485760, s.temp, s.temp, boolToInt(s.temp != nil)); err != nil { + t.Fatalf("seed system bucket: %v", err) + } + } + iface := []struct{ start, name string; rx, tx float64 }{ + {"2026-09-19 08:00:00", "ether1", 1e6, 5e5}, + {"2026-09-19 08:00:00", "ether2", 2e6, 1e6}, + {"2026-09-19 08:15:00", "ether1", 3e6, 1.5e6}, + {"2026-09-19 08:15:00", "ether2", 4e6, 2e6}, + {"2026-09-19 08:30:00", "ether1", 5e6, 2.5e6}, + {"2026-09-19 08:30:00", "ether2", 6e6, 3e6}, + // Partial bucket: ether2 absent โ€” HAVING must exclude it. Its 9e7 outlier + // would otherwise drag the rx median from 9e6 to 11e6. Complete buckets + // have rx sums 3e6, 7e6, 11e6, 17e6 โ†’ median 9e6 (tx: 4.5e6). + {"2026-09-19 08:45:00", "ether1", 9e7, 9e7}, + {"2026-09-19 09:00:00", "ether1", 8e6, 4e6}, + {"2026-09-19 09:00:00", "ether2", 9e6, 5e6}, + } + for _, f := range iface { + if _, err := database.SqlDB.Exec(` + INSERT INTO interface_metric_buckets (router_id, interface_name, bucket_start, samples, + rx_rate_bps_sum, rx_rate_bps_max, tx_rate_bps_sum, tx_rate_bps_max) + VALUES (1, ?, ?, 60, ?, ?, ?, ?)`, + f.name, f.start, f.rx, f.rx, f.tx, f.tx); err != nil { + t.Fatalf("seed interface bucket: %v", err) + } + } + if err := database.SetSetting("monitored_interfaces_1", `["ether1","ether2"]`, ""); err != nil { + t.Fatalf("seed monitored: %v", err) + } + if _, err := database.SqlDB.Exec(` + INSERT INTO users (id, name, router_id, avatar_icon, speed_limit, is_paused, priority, sort_order) + VALUES (1, 'Alice', 1, 'a', '0', 0, 0, 0), (2, 'Bob', 1, 'b', '0', 0, 0, 1)`); err != nil { + t.Fatalf("seed users: %v", err) + } + if _, err := database.SqlDB.Exec(` + INSERT INTO devices (id, user_id, router_id, mac_address, is_active, is_hidden, is_deleted, is_container, speed_limit, is_paused, priority, last_seen) + VALUES (1, 1, 1, 'AA:BB:CC:00:00:01', 1, 0, 0, 0, '0', 0, 0, datetime('now')), + (2, 2, 1, 'AA:BB:CC:00:00:02', 0, 0, 0, 0, '0', 0, 0, datetime('now'))`); err != nil { + t.Fatalf("seed devices: %v", err) + } +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func decodeBootstrap(t *testing.T, frame []byte) map[string]interface{} { + t.Helper() + var msg map[string]interface{} + if err := json.Unmarshal(frame, &msg); err != nil { + t.Fatalf("frame is not valid JSON: %v", err) + } + return msg +} + +func TestBootstrapFrameMedians(t *testing.T) { + database := openBootstrapDB(t) + seedBootstrapHistory(t, database) + + frame := buildBootstrapFrame(database, 1) + if frame == nil { + t.Fatal("expected a bootstrap frame, got none") + } + msg := decodeBootstrap(t, frame) + if msg["type"] != "telemetry_tick" { + t.Fatalf("type: %v", msg["type"]) + } + if msg["bootstrap"] != true { + t.Fatalf("bootstrap flag: %v", msg["bootstrap"]) + } + router, _ := msg["router"].(map[string]interface{}) + if router == nil { + t.Fatal("frame has no router object") + } + + // CPU median over {10,20,30,40,50} = 30; memory % median = 60. + if got := router["cpu_load"]; got != 30.0 { + t.Errorf("cpu_load: got %v want 30", got) + } + if got := router["memory_usage_pct"]; got != 60.0 { + t.Errorf("memory_usage_pct: got %v want 60", got) + } + // Temperature: 4 non-null of 5 buckets, median of {44,46,50,52} = 48. + if got := router["temperature"]; got != 48.0 { + t.Errorf("temperature: got %v want 48", got) + } + // WAN: complete buckets have rx sums 3e6/7e6/11e6/17e6 (the partial 08:45 + // bucket is excluded by the coverage HAVING); median = 9e6. + if got := router["wan_rx_bps"]; got != 9e6 { + t.Errorf("wan_rx_bps: got %v want 9000000", got) + } + if got := router["wan_tx_bps"]; got != 4.5e6 { + t.Errorf("wan_tx_bps: got %v want 4500000", got) + } + // Counts come from the live DB, not history. + if router["user_count"] != float64(2) { + t.Errorf("user_count: %v", router["user_count"]) + } + if router["client_device_count"] != float64(2) || router["active_clients"] != float64(1) { + t.Errorf("devices: total=%v active=%v", router["client_device_count"], router["active_clients"]) + } + // Memory: total 1024 MB; median used_bytes 629145600 = 600 MB, so free โ‰ˆ 424. + if got, ok := router["total_memory_mb"].(float64); !ok || got != 1024 { + t.Errorf("total_memory_mb: %v", router["total_memory_mb"]) + } + if got, ok := router["free_memory_mb"].(float64); !ok || got < 420 || got > 428 { + t.Errorf("free_memory_mb: %v want ~424", got) + } + if mon, ok := router["monitored_interfaces"].([]interface{}); !ok || len(mon) != 2 { + t.Errorf("monitored_interfaces: %v", router["monitored_interfaces"]) + } +} + +func TestBootstrapFrameAbsentWhenNoHistory(t *testing.T) { + database := openBootstrapDB(t) + seedBootstrapHistory(t, database) + // Delete every bucket: the frame must NOT be emitted with zeroed fields. + if _, err := database.SqlDB.Exec("DELETE FROM system_metric_buckets; DELETE FROM interface_metric_buckets"); err != nil { + t.Fatalf("clear buckets: %v", err) + } + frame := buildBootstrapFrame(database, 1) + if frame == nil { + t.Fatal("expected a counts-only frame") + } + router := decodeBootstrap(t, frame)["router"].(map[string]interface{}) + for _, key := range []string{"cpu_load", "memory_usage_pct", "wan_rx_bps", "wan_tx_bps", "temperature"} { + if _, present := router[key]; present { + t.Errorf("%s present without history โ€” absent must stay absent", key) + } + } + // Roster counts survive: they are live DB facts, not history medians. + if router["user_count"] != float64(2) { + t.Errorf("user_count: %v", router["user_count"]) + } +} + +func TestBootstrapFrameUnknownAndDefaultRouter(t *testing.T) { + database := openBootstrapDB(t) + seedBootstrapHistory(t, database) + + if frame := buildBootstrapFrame(database, 77); frame != nil { + t.Error("unknown router id must not produce a frame") + } + // routerID 0 resolves to the default router. + frame := buildBootstrapFrame(database, 0) + if frame == nil { + t.Fatal("default router should resolve") + } + if id := decodeBootstrap(t, frame)["router_id"]; id != float64(1) { + t.Errorf("router_id: %v want 1", id) + } +} + +func TestHubBootstrapFallbackOnColdConnect(t *testing.T) { + database := openBootstrapDB(t) + seedBootstrapHistory(t, database) + + hub := NewHub() + hub.AttachDatabase(database) + + wsURL, closeServer := startHubTestServer(t, hub) + defer closeServer() + + conn := dialWS(t, wsURL, 1) + defer conn.Close() + + // First message: connected. Second: the bootstrap frame, because no live + // tick has ever been broadcast for this router. + initMsg := readWSJSON(t, conn) + if initMsg["type"] != "connected" { + t.Fatalf("expected connected, got %v", initMsg["type"]) + } + boot := readWSJSON(t, conn) + if boot["type"] != "telemetry_tick" || boot["bootstrap"] != true { + t.Fatalf("expected bootstrap tick, got %v", boot) + } + + // A live tick now overrides the cache: a second cold connection must get + // the LIVE frame, not a stale bootstrap. + hub.BroadcastRouter(1, true, map[string]interface{}{ + "type": "telemetry_tick", "router_id": 1, "live": true, + }) + conn2 := dialWS(t, wsURL, 1) + defer conn2.Close() + readWSJSON(t, conn2) // connected + second := readWSJSON(t, conn2) + if second["bootstrap"] == true { + t.Error("live tick did not take precedence over the bootstrap frame") + } + if second["live"] != true { + t.Errorf("expected the live frame, got %v", second) + } +} diff --git a/backend-go/internal/api/ws.go b/backend-go/internal/api/ws.go index 4d9284a..31fdb2a 100644 --- a/backend-go/internal/api/ws.go +++ b/backend-go/internal/api/ws.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/masseselsev/mikroman/internal/db" ) var upgrader = websocket.Upgrader{ @@ -33,6 +34,11 @@ type Hub struct { mu sync.RWMutex clients map[*clientConn]bool lastFrames map[int][]byte // routerID -> cached telemetry frame (0 = default router) + // bootDB enables the bootstrap frame: history summarised for a client that + // connects before any live tick exists. nil (unit tests of the hub alone) + // keeps the old "wait for the first tick" behavior. + bootDB *db.DB + bootCache map[int]bootstrapEntry } func NewHub() *Hub { @@ -70,6 +76,15 @@ func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) { } h.mu.Unlock() + // No live tick for this router has ever arrived (cold server, or a router + // that is down right now). Fall back to a frame summarised from stored + // history so the dashboard is not blank until the first poll succeeds. The + // frame is tagged "bootstrap" and carries bootstrapped:true in the router + // object; the first real tick overwrites both hub caches and the UI alike. + if cachedFrame == nil { + cachedFrame = h.bootstrapFrame(routerID) + } + defer func() { h.mu.Lock() delete(h.clients, client) diff --git a/frontend/src/components/TelemetryBar.jsx b/frontend/src/components/TelemetryBar.jsx index 6d6474c..aa2de85 100644 --- a/frontend/src/components/TelemetryBar.jsx +++ b/frontend/src/components/TelemetryBar.jsx @@ -137,12 +137,12 @@ function PublicIpLink({ ip, service, t }) { * entirely, since its globe icon already says what the row is about and the * text was the tightest-fitting thing in the row. */ -function Tile({ icon, tone, label, value, sub, history, historyMax, onClick, title, valueSize = 'var(--fs-md)' }) { +function Tile({ icon, tone, label, value, sub, history, historyMax, onClick, title, valueSize = 'var(--fs-md)', dimmed = false }) { const subLines = (Array.isArray(sub) ? sub : [sub]).filter(Boolean); return (
@@ -216,6 +216,26 @@ export function TelemetryBar({ router, activeRouter, interfaces = [], onNavigate useEffect(() => { if (!router) return; + // A bootstrap frame carries history medians, not samples: seed each + // sparkline with a flat line at the median instead of appending, so the + // bar looks occupied from the first paint. The first live tick then + // appends onto the seed line โ€” the median "gradually updates" toward the + // real curve instead of the bar snapping from empty to busy. + if (router.bootstrapped) { + const flat = (setter, value) => { + if (value != null && !Number.isNaN(value)) setter([value]); + }; + flat(setRxHistory, router.wan_rx_bps); + flat(setTxHistory, router.wan_tx_bps); + flat(setCpuHistory, router.cpu_load); + if (router.total_memory_mb) { + setMemHistory(prev => prev.length + ? prev + : [((router.total_memory_mb - (router.free_memory_mb || 0)) / router.total_memory_mb) * 100]); + } + flat(setTempHistory, router.temperature); + return; + } setRxHistory(prev => pushHistory(prev, router.wan_rx_bps)); setTxHistory(prev => pushHistory(prev, router.wan_tx_bps)); setCpuHistory(prev => pushHistory(prev, router.cpu_load)); @@ -452,6 +472,10 @@ export function TelemetryBar({ router, activeRouter, interfaces = [], onNavigate ? `${t('configure_interfaces_hint')}\n${t('wan_label')}: ${monitored.join(', ')}` : `${t('wan_none_warning')}\n${t('configure_interfaces_hint')}`; + // Values summarised from history carry a subdued look until the first live + // tick โ€” the numbers are medians of recent buckets, honest but not "now". + const bootstrapped = !!router.bootstrapped; + return ( <>
@@ -482,6 +507,7 @@ export function TelemetryBar({ router, activeRouter, interfaces = [], onNavigate value={formatSpeed(router.wan_tx_bps, speedUnit)} sub={wanSub} history={txHistory} + dimmed={bootstrapped} onClick={openConfigModal} title={wanTileTitle} /> @@ -498,6 +524,7 @@ export function TelemetryBar({ router, activeRouter, interfaces = [], onNavigate sub={cpuLines} history={cpuHistory} historyMax={100} + dimmed={bootstrapped} onClick={goHealth} title={cpuTitle} /> @@ -513,6 +540,7 @@ export function TelemetryBar({ router, activeRouter, interfaces = [], onNavigate sub={memPct !== null ? `${memPct}% ${t('used_label')} ยท ${usedMemMb}/${totalMemMb} MB` : ''} history={memHistory} historyMax={100} + dimmed={bootstrapped} onClick={goHealth} title={onNavigate ? t('open_health_hint') : undefined} /> @@ -524,6 +552,7 @@ export function TelemetryBar({ router, activeRouter, interfaces = [], onNavigate value={router.temperature != null ? `${router.temperature}ยฐC` : 'โ€”'} sub={tempThreshold ? `${t('threshold_label')} ${tempThreshold}ยฐC` : ''} history={tempHistory} + dimmed={bootstrapped} onClick={goHealth} title={onNavigate ? t('open_health_hint') : undefined} /> diff --git a/frontend/src/components/TelemetryBar.test.jsx b/frontend/src/components/TelemetryBar.test.jsx index 9afe00c..75c16e7 100644 --- a/frontend/src/components/TelemetryBar.test.jsx +++ b/frontend/src/components/TelemetryBar.test.jsx @@ -133,6 +133,41 @@ describe('TelemetryBar redesigned tile labels', () => { }); }); +describe('TelemetryBar bootstrap frame', () => { + const sparklines = (container) => container.querySelectorAll('svg[viewBox="0 0 100 18"]'); + + it('fills the tiles from history medians and dims them until a live tick lands', () => { + // The hub's cold-start frame: same schema as a tick, tagged bootstrapped. + const frame = { + cpu_load: 30, memory_usage_pct: 60, free_memory_mb: 424, total_memory_mb: 1024, + temperature: 48, wan_rx_bps: 9000000, wan_tx_bps: 4500000, + user_count: 2, client_device_count: 2, active_clients: 1, + monitored_interfaces: ['ether1', 'ether2'], bootstrapped: true, + }; + const { container, rerender } = renderWithProviders( + + ); + + // The medians are on screen, not dashes: 30% CPU, 9.0 Mbps download. + expect(screen.getByText('30%')).toBeInTheDocument(); + expect(screen.getByText('9.0 Mbps')).toBeInTheDocument(); + // Measurement tiles carry the dimmed marker; the values themselves are not + // hidden, only visibly "not live yet". + expect(container.querySelectorAll('.tile-bootstrapped').length).toBe(5); + + // The first real tick lifts the dim, replaces the figures, and the seeded + // median becomes the anchor of the now-two-point sparkline. + rerender(); + expect(container.querySelectorAll('.tile-bootstrapped').length).toBe(0); + expect(screen.getByText('12%')).toBeInTheDocument(); + expect(screen.getByText('1.2 Kbps')).toBeInTheDocument(); + expect(sparklines(container).length).toBeGreaterThan(0); + }); +}); + describe('TelemetryBar sparklines across a router switch', () => { // The sparkline SVG is identifiable by its own viewBox; lucide icons in the // same tiles are also SVGs, so anything looser would count those too. diff --git a/frontend/src/index.css b/frontend/src/index.css index b995765..10bf5f4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -877,6 +877,16 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover { transition: border-color 0.2s ease, box-shadow 0.2s ease; } +/* Bootstrap tile: the figures are medians summarised from stored history, not + readings of this moment. One notch of opacity states that without a label โ€” + the bar reads as a whole, but a bootstrapped tile visibly lifts to full + strength the instant the first live tick lands. */ +.tile-bootstrapped .tile-value, +.tile-bootstrapped svg { + opacity: 0.65; + transition: opacity 0.4s ease; +} + .tile.is-clickable { cursor: pointer; } From 818eb07b8114a0956e50c851f43fd31d0d62573f Mon Sep 17 00:00:00 2001 From: masseselsev Date: Sat, 19 Sep 2026 11:32:51 +0000 Subject: [PATCH 2/2] fix: address bootstrap review findings and stop the double load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HandleWS re-checks the live frame cache after building a bootstrap: a tick landing during the history queries can no longer be followed by the stale median frame it would overwrite. - BroadcastRouter revokes the bootstrap cache for the router and the default alias 0, so a cached pre-live frame never outlives the first live tick. - bootstrapSystemReading derives memory_usage_pct from the median free/total instead of medians of independent columns that could print an impossible triple; both readers surface rows.Err(). - The frontend seeds every sparkline explicitly: a metric missing from the bootstrap clears the previous router's line rather than lingering. - The telemetry hook no longer opens a probe socket while the active router id is unresolved; the hub answered it with the default router's replay, and the id settling a moment later tore it down and repeated everything โ€” the bar loaded twice per refresh. The initial-load effect no longer depends on activeRouter?.id (it re-ran the whole load when the default was adopted), and loadRouters fires loadData the instant it adopts a router instead of leaving the user/device tiles queued behind the /routers live probes. Tested: 4 bootstrap tests extended (cache revocation asserted), hook tests pin the null-id silence, full gates green (go vet+tests, 397 vitest, build, sweep). --- .../internal/api/telemetry_bootstrap.go | 25 +++++++++--- .../internal/api/telemetry_bootstrap_test.go | 24 ++++++++++-- backend-go/internal/api/ws.go | 22 ++++++++++- frontend/src/App.jsx | 38 +++++++++++++++---- frontend/src/components/TelemetryBar.jsx | 20 +++++----- frontend/src/hooks/useWebSocketTelemetry.js | 12 ++++++ .../src/hooks/useWebSocketTelemetry.test.jsx | 32 ++++++++++++---- 7 files changed, 138 insertions(+), 35 deletions(-) diff --git a/backend-go/internal/api/telemetry_bootstrap.go b/backend-go/internal/api/telemetry_bootstrap.go index 999ee8b..44cacb5 100644 --- a/backend-go/internal/api/telemetry_bootstrap.go +++ b/backend-go/internal/api/telemetry_bootstrap.go @@ -180,7 +180,7 @@ func resolveBootstrapRouter(database *db.DB, routerID int) (int, bool) { // by the median, which is exactly what a placeholder is allowed to do. func bootstrapSystemReading(database *db.DB, routerID int) (cpu, memPct, freeMB, totalMB float64, temp *float64, ok bool) { rows, err := database.SqlDB.Query(` - SELECT cpu_load_avg, memory_usage_pct_avg, + SELECT cpu_load_avg, memory_used_bytes_avg, memory_total_bytes_max, temperature_avg FROM system_metric_buckets WHERE router_id = ? @@ -191,16 +191,15 @@ func bootstrapSystemReading(database *db.DB, routerID int) (cpu, memPct, freeMB, } defer rows.Close() - var cpus, pcts, frees, totals []float64 + var cpus, frees, totals []float64 var temps []float64 for rows.Next() { - var cpuAvg, pctAvg, usedAvg, totalMax float64 + var cpuAvg, usedAvg, totalMax float64 var tempAvg sql.NullFloat64 - if err := rows.Scan(&cpuAvg, &pctAvg, &usedAvg, &totalMax, &tempAvg); err != nil { + if err := rows.Scan(&cpuAvg, &usedAvg, &totalMax, &tempAvg); err != nil { continue } cpus = append(cpus, cpuAvg) - pcts = append(pcts, pctAvg) totalMBv := totalMax / (1024 * 1024) frees = append(frees, math.Max(totalMBv-usedAvg/(1024*1024), 0)) totals = append(totals, totalMBv) @@ -211,11 +210,22 @@ func bootstrapSystemReading(database *db.DB, routerID int) (cpu, memPct, freeMB, if len(cpus) == 0 { return 0, 0, 0, 0, nil, false } + if rows.Err() != nil { + return 0, 0, 0, 0, nil, false + } if len(temps) > 0 { t := medianFloat(temps) temp = &t } - return medianFloat(cpus), medianFloat(pcts), medianFloat(frees), medianFloat(totals), temp, true + // The memory triple must be coherent (free + used == total, pct matching + // them): medians taken independently from the stored pct column could land + // on a bucket whose footprint differed and print an impossible split. The + // derived pct says exactly what the two printed MB figures agree on. + freeMed, totalMed := medianFloat(frees), medianFloat(totals) + if totalMed > 0 { + memPct = math.Min(math.Max((totalMed-freeMed)/totalMed*100, 0), 100) + } + return medianFloat(cpus), memPct, freeMed, totalMed, temp, true } // bootstrapWanReading returns median rx/tx of the SUM of the monitored @@ -265,6 +275,9 @@ func bootstrapWanReading(database *db.DB, routerID int) (rx, tx float64, ok bool if len(rxs) == 0 { return 0, 0, false } + if rows.Err() != nil { + return 0, 0, false + } return medianFloat(rxs), medianFloat(txs), true } diff --git a/backend-go/internal/api/telemetry_bootstrap_test.go b/backend-go/internal/api/telemetry_bootstrap_test.go index e98e650..fd6fbfb 100644 --- a/backend-go/internal/api/telemetry_bootstrap_test.go +++ b/backend-go/internal/api/telemetry_bootstrap_test.go @@ -90,7 +90,10 @@ func seedBootstrapHistory(t *testing.T, database *db.DB) { t.Fatalf("seed system bucket: %v", err) } } - iface := []struct{ start, name string; rx, tx float64 }{ + iface := []struct { + start, name string + rx, tx float64 + }{ {"2026-09-19 08:00:00", "ether1", 1e6, 5e5}, {"2026-09-19 08:00:00", "ether2", 2e6, 1e6}, {"2026-09-19 08:15:00", "ether1", 3e6, 1.5e6}, @@ -165,12 +168,13 @@ func TestBootstrapFrameMedians(t *testing.T) { t.Fatal("frame has no router object") } - // CPU median over {10,20,30,40,50} = 30; memory % median = 60. + // CPU median over {10,20,30,40,50} = 30. Memory pct is derived from the + // median free/total (not the stored pct column): (1024-424)/1024 = 58.6%. if got := router["cpu_load"]; got != 30.0 { t.Errorf("cpu_load: got %v want 30", got) } - if got := router["memory_usage_pct"]; got != 60.0 { - t.Errorf("memory_usage_pct: got %v want 60", got) + if got := router["memory_usage_pct"]; got != 58.6 { + t.Errorf("memory_usage_pct: got %v want 58.6", got) } // Temperature: 4 non-null of 5 buckets, median of {44,46,50,52} = 48. if got := router["temperature"]; got != 48.0 { @@ -282,4 +286,16 @@ func TestHubBootstrapFallbackOnColdConnect(t *testing.T) { if second["live"] != true { t.Errorf("expected the live frame, got %v", second) } + + // The broadcast also revokes the bootstrap cache itself โ€” for the actual + // router id AND for the alias 0 it was built under โ€” so a later connect + // never races with a minute-old median frame; HandleWS prefers lastFrames, + // and a cold router_id can only get a freshly built bootstrap, never a + // cached one from before the router came alive. + hub.mu.RLock() + cached := len(hub.bootCache) + hub.mu.RUnlock() + if cached != 0 { + t.Errorf("bootstrap cache survived the live tick: %d entries", cached) + } } diff --git a/backend-go/internal/api/ws.go b/backend-go/internal/api/ws.go index 31fdb2a..9643a71 100644 --- a/backend-go/internal/api/ws.go +++ b/backend-go/internal/api/ws.go @@ -81,8 +81,21 @@ func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) { // history so the dashboard is not blank until the first poll succeeds. The // frame is tagged "bootstrap" and carries bootstrapped:true in the router // object; the first real tick overwrites both hub caches and the UI alike. + // The live cache is re-checked after building: a tick may land while the + // history queries run, and a frame claiming "now" must not arrive after the + // real "now" it would overwrite. if cachedFrame == nil { - cachedFrame = h.bootstrapFrame(routerID) + boot := h.bootstrapFrame(routerID) + h.mu.RLock() + live := h.lastFrames[routerID] + if live == nil { + live = h.lastFrames[0] + } + h.mu.RUnlock() + cachedFrame = live + if cachedFrame == nil { + cachedFrame = boot + } } defer func() { @@ -146,6 +159,13 @@ func (h *Hub) BroadcastRouter(routerID int, isDefault bool, event interface{}) { if isDefault { h.lastFrames[0] = data } + // A bootstrap frame summarised from history is stale the moment a live tick + // exists: drop the cache for this router and for the alias id 0 that the + // bootstrap resolves against the default router. + delete(h.bootCache, routerID) + if isDefault { + delete(h.bootCache, 0) + } targets := make([]*clientConn, 0, len(h.clients)) for c := range h.clients { if c.routerID == routerID || (c.routerID == 0 && isDefault) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5920602..4c40e2f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -154,6 +154,8 @@ export function App() { // refresh - the single biggest source of "the dashboard feels sluggish". // Split out, it runs on mount, on a switch, and every 30s for the selector's // online dots, while loadData() below only moves user/device data. + // Returns true when this pass *adopted* a router (resolved an id the data + // load was waiting on) and fired loadData itself. const loadRouters = async () => { const routersRes = await api.getRouters().catch(() => ({ data: [] })); const routerList = routersRes.data || []; @@ -168,25 +170,35 @@ export function App() { : null; if (!fresh) { const current = routerList.find(r => r.is_default) || routerList[0] || null; + // The adoption moment is a *new* target for loadData: open its WS socket + // and fetch its data as soon as the id exists, rather than returning and + // waiting for the effect's sequential `await loadData()` (and, before + // that, for the full /routers response โ€” which includes a live probe of + // every other router, two seconds per dead box). activeRouterIdRef.current = current ? current.id : null; setActiveRouter(current || null); - } else { - setActiveRouter(prev => (prev && prev.is_online === fresh.is_online ? prev : fresh)); + if (current) void loadData(current.id); + return !!current; } + setActiveRouter(prev => (prev && prev.is_online === fresh.is_online ? prev : fresh)); + return false; }; // Full refresh: the router list AND the active router's data. Used where the // set of routers may have changed (first-run wizard, add/remove in Settings), - // not on the fast data poll. + // not on the fast data poll. loadRouters fires the data load itself when it + // adopted a new router; only fill in a second load when it did not. const reloadAll = async () => { - await loadRouters(); - await loadData(); + const adopted = await loadRouters(); + if (!adopted) await loadData(); }; const loadData = async (routerIdOverride = null) => { // The router this load is for: an explicit override wins, otherwise the ref // (never the closure's activeRouter, which lags a switch). Null means the // first loadRouters() has not resolved a target yet - nothing to fetch. + // (The server's no-router_id path is a cross-router union, so guessing by + // omitting the id is not an available shortcut โ€” the wait is short.) const effectiveId = routerIdOverride ?? activeRouterIdRef.current; if (effectiveId == null) return; try { @@ -258,12 +270,22 @@ export function App() { } }; + // The initial load + the polls, keyed on auth alone. activeRouter?.id must + // NOT be a dependency: resolving the default router after mount (null -> + // an id) would re-run this effect and repeat the whole loadRouters+loadData + // sequence, so every refresh fetched the user/device/alert data twice. + // Router switches reload through handleSelectRouter, and both poll + // intervals read the active id from a ref โ€” neither needs the effect to + // restart. useEffect(() => { if (authEnabled && !isAuthenticated) return; let cancelled = false; (async () => { - await loadRouters(); - if (!cancelled) await loadData(); + // loadRouters fires loadData itself when it adopts a router (the id is + // only known at that moment); the sequential call below covers the + // other path โ€” the id was already resolved, e.g. after re-login. + const adopted = await loadRouters(); + if (!cancelled && !adopted) await loadData(); })(); // Data poll: fast, so a new active device or an IP shift shows quickly. const dataPoll = setInterval(() => loadData(), 6000); @@ -274,7 +296,7 @@ export function App() { clearInterval(dataPoll); clearInterval(routerPoll); }; - }, [activeRouter?.id, authEnabled, isAuthenticated]); + }, [authEnabled, isAuthenticated]); const [interfacesOpen, setInterfacesOpen] = useState(false); const [draggedUserId, setDraggedUserId] = useState(null); diff --git a/frontend/src/components/TelemetryBar.jsx b/frontend/src/components/TelemetryBar.jsx index aa2de85..3b4883c 100644 --- a/frontend/src/components/TelemetryBar.jsx +++ b/frontend/src/components/TelemetryBar.jsx @@ -222,18 +222,20 @@ export function TelemetryBar({ router, activeRouter, interfaces = [], onNavigate // appends onto the seed line โ€” the median "gradually updates" toward the // real curve instead of the bar snapping from empty to busy. if (router.bootstrapped) { - const flat = (setter, value) => { - if (value != null && !Number.isNaN(value)) setter([value]); + // Seed every series explicitly: a field missing from the bootstrap must + // clear the previous router's line, not let it linger on the new one. + const seed = (setter, value) => { + setter(value != null && !Number.isNaN(value) ? [value] : []); }; - flat(setRxHistory, router.wan_rx_bps); - flat(setTxHistory, router.wan_tx_bps); - flat(setCpuHistory, router.cpu_load); + seed(setRxHistory, router.wan_rx_bps); + seed(setTxHistory, router.wan_tx_bps); + seed(setCpuHistory, router.cpu_load); + seed(setTempHistory, router.temperature); if (router.total_memory_mb) { - setMemHistory(prev => prev.length - ? prev - : [((router.total_memory_mb - (router.free_memory_mb || 0)) / router.total_memory_mb) * 100]); + setMemHistory([((router.total_memory_mb - (router.free_memory_mb || 0)) / router.total_memory_mb) * 100]); + } else { + setMemHistory([]); } - flat(setTempHistory, router.temperature); return; } setRxHistory(prev => pushHistory(prev, router.wan_rx_bps)); diff --git a/frontend/src/hooks/useWebSocketTelemetry.js b/frontend/src/hooks/useWebSocketTelemetry.js index a68f4ba..4ab05ae 100644 --- a/frontend/src/hooks/useWebSocketTelemetry.js +++ b/frontend/src/hooks/useWebSocketTelemetry.js @@ -6,6 +6,18 @@ export function useWebSocketTelemetry(routerId = null) { const wsRef = useRef(null); useEffect(() => { + // Until the app has resolved which router is active (first render, or the + // brief gap of a router deletion), do not open a socket at all. A probe + // connection on an unresolved id is not free: the hub answers it with the + // default router's replay/bootstrap, and then the id settling (undefined + // โ†’ the real id) tears it down and repeats the whole exchange โ€” the bar + // visibly loads twice on every refresh. The default-router semantics + // (router_id=0) stay available to clients that dial in deliberately. + if (routerId == null) { + setTelemetry(null); + setIsConnected(false); + return undefined; + } // Drop the previous router's last frame the moment the selection changes, // so its CPU / traffic / user list do not linger on screen until the new // socket delivers its first tick. diff --git a/frontend/src/hooks/useWebSocketTelemetry.test.jsx b/frontend/src/hooks/useWebSocketTelemetry.test.jsx index 191b208..439c7c5 100644 --- a/frontend/src/hooks/useWebSocketTelemetry.test.jsx +++ b/frontend/src/hooks/useWebSocketTelemetry.test.jsx @@ -62,14 +62,32 @@ describe('useWebSocketTelemetry', () => { const visibleSockets = () => FakeWebSocket.instances.filter((s) => !s.closed); - it('opens exactly one socket per mounted page', () => { + it('stays silent while the active router id is unresolved', () => { + // The app opens with the router not yet adopted (null). Connecting then is + // what made the telemetry bar load twice per refresh: the hub answered the + // probe with a default-router replay, the id settled a moment later, and + // the socket was torn down and rebuilt. No id -> no socket. renderHook(() => useWebSocketTelemetry(null)); + expect(FakeWebSocket.instances).toHaveLength(0); + // undefined is the other spelling of "not resolved yet". + renderHook(() => useWebSocketTelemetry(undefined)); + expect(FakeWebSocket.instances).toHaveLength(0); + }); + + it('connects once the router id is known', () => { + renderHook(() => useWebSocketTelemetry(7)); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(FakeWebSocket.instances[0].url).toContain('router_id=7'); + }); + + it('opens exactly one socket per mounted page', () => { + renderHook(() => useWebSocketTelemetry(1)); expect(FakeWebSocket.instances).toHaveLength(1); expect(FakeWebSocket.instances[0].url).toContain('/ws/telemetry'); }); it('closes the socket when the tab is hidden and stays closed', () => { - const { unmount } = renderHook(() => useWebSocketTelemetry(null)); + const { unmount } = renderHook(() => useWebSocketTelemetry(1)); const first = FakeWebSocket.instances[0]; // `open()` runs the hook's onopen, which sets state; keep it inside act(). act(() => { first.open(); }); @@ -86,7 +104,7 @@ describe('useWebSocketTelemetry', () => { it('does not reconnect while hidden', () => { vi.useFakeTimers(); - renderHook(() => useWebSocketTelemetry(null)); + renderHook(() => useWebSocketTelemetry(1)); const first = FakeWebSocket.instances[0]; // `open()` runs the hook's onopen, which sets state; keep it inside act(). act(() => { first.open(); }); @@ -105,7 +123,7 @@ describe('useWebSocketTelemetry', () => { }); it('reconnects when the tab becomes visible again', () => { - renderHook(() => useWebSocketTelemetry(null)); + renderHook(() => useWebSocketTelemetry(1)); act(() => { setVisibility('hidden'); document.dispatchEvent(new Event('visibilitychange')); @@ -119,7 +137,7 @@ describe('useWebSocketTelemetry', () => { }); it('never stacks a second live socket for the same page', () => { - renderHook(() => useWebSocketTelemetry(null)); + renderHook(() => useWebSocketTelemetry(1)); act(() => { FakeWebSocket.instances[0].open(); }); // Two visibility flips in a row must not produce two connections. @@ -132,7 +150,7 @@ describe('useWebSocketTelemetry', () => { it('retries after an unexpected drop while visible', () => { vi.useFakeTimers(); - renderHook(() => useWebSocketTelemetry(null)); + renderHook(() => useWebSocketTelemetry(1)); const first = FakeWebSocket.instances[0]; // `open()` runs the hook's onopen, which sets state; keep it inside act(). act(() => { first.open(); }); @@ -146,7 +164,7 @@ describe('useWebSocketTelemetry', () => { it('tears the socket down on unmount without scheduling a reconnect', () => { vi.useFakeTimers(); - const { unmount } = renderHook(() => useWebSocketTelemetry(null)); + const { unmount } = renderHook(() => useWebSocketTelemetry(1)); const first = FakeWebSocket.instances[0]; // `open()` runs the hook's onopen, which sets state; keep it inside act(). act(() => { first.open(); });