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..44cacb5 --- /dev/null +++ b/backend-go/internal/api/telemetry_bootstrap.go @@ -0,0 +1,313 @@ +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_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, frees, totals []float64 + var temps []float64 + for rows.Next() { + var cpuAvg, usedAvg, totalMax float64 + var tempAvg sql.NullFloat64 + if err := rows.Scan(&cpuAvg, &usedAvg, &totalMax, &tempAvg); err != nil { + continue + } + cpus = append(cpus, cpuAvg) + 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 rows.Err() != nil { + return 0, 0, 0, 0, nil, false + } + if len(temps) > 0 { + t := medianFloat(temps) + temp = &t + } + // 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 +// 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 + } + if rows.Err() != nil { + 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..fd6fbfb --- /dev/null +++ b/backend-go/internal/api/telemetry_bootstrap_test.go @@ -0,0 +1,301 @@ +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 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 != 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 { + 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) + } + + // 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 4d9284a..9643a71 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,28 @@ 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. + // 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 { + 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() { h.mu.Lock() delete(h.clients, client) @@ -131,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 6d6474c..3b4883c 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 (