package api // The Network tab: the machine's interfaces, how the internet looks from here, and the speed // test. One machine at a time, like every other tab — the test runs on the machine in the // switcher, so the number is that machine's line and not the hub's. import ( "encoding/json" "log" "net/http" "time" "hamlab/internal/network" "hamlab/internal/sensors" "hamlab/internal/store" ) // networkOverview is everything the tab draws at once: the interfaces, the outside view, the // last test and the ones before it. func (s *Server) networkOverview(w http.ResponseWriter, _ *http.Request) { run := s.net.Test.State() jsonOK(w, 200, map[string]any{ "report": network.Look(s.hostName()), "outside": s.net.Outside(), "run": run, "history": s.net.Test.History(), "method": network.Method, }) } func (s *Server) speedtestState(w http.ResponseWriter, _ *http.Request) { jsonOK(w, 200, s.net.Test.State()) } func (s *Server) speedtestStart(w http.ResponseWriter, r *http.Request) { run, err := s.net.Test.Start() if err != nil { jsonErr(w, 409, err.Error()) return } log.Printf("speed test started on %s (by %s)", s.hostName(), clientIP(r)) jsonOK(w, 200, run) } func (s *Server) speedtestCancel(w http.ResponseWriter, r *http.Request) { if !s.net.Test.Cancel() { jsonErr(w, 404, "no speed test is running") return } log.Printf("speed test cancelled on %s (by %s)", s.hostName(), clientIP(r)) w.WriteHeader(204) } // ---- keeping the results ---- // savedNetwork is what survives a restart: the recent results, so the history is a history // rather than whatever has happened since the daemon last started. type savedNetwork struct { Results []network.Result `json:"results,omitempty"` } func (s *Server) loadNetwork() []network.Result { var saved savedNetwork var raw json.RawMessage s.store.View(func(st *store.State) { raw = st.Network }) if len(raw) > 0 { if err := json.Unmarshal(raw, &saved); err != nil { log.Printf("network: kept results unreadable (%v); starting a fresh history", err) } } return saved.Results } // keepResult is called when a test finishes: append it to what is on disk. func (s *Server) keepResult(r network.Result) { if r.Error != "" { log.Printf("speed test on %s failed: %s", s.hostName(), r.Error) } else { log.Printf("speed test on %s: down %s, up %s, latency %.1f ms via %s", s.hostName(), network.Mbits(r.DownMbps), network.Mbits(r.UpMbps), r.LatencyMs, r.Colo) } results := s.net.Test.History() b, err := json.Marshal(savedNetwork{Results: results}) if err != nil { return } if err := s.store.Update(func(st *store.State) { st.Network = b }); err != nil { log.Printf("network: %v", err) } } // ---- the speed test as sensors ---- // netSensors publishes the last speed test as ordinary sensors, so the line can be a widget, // a graph, an alert or a Prometheus series like anything else Ham Lab measures. Each one's // timestamp is when the test ran, which is how a stale reading says so. type netSensors struct{ w *network.Watcher } func (n netSensors) Sensors() []sensors.Sensor { var out []sensors.Sensor if o := n.w.Outside(); o.IP != "" { at := o.At.UnixMilli() out = append(out, sensors.Sensor{ID: "net.internet.ip", Name: "Public address", Kind: "state", Value: o.IP, T: at}) if o.Colo != "" { out = append(out, sensors.Sensor{ID: "net.internet.edge", Name: "Internet edge", Kind: "state", Value: o.Colo, T: at}) } } run := n.w.Test.State() if run.Result == nil || run.Result.Error != "" { return out } r := run.Result at := r.At.UnixMilli() return append(out, sensors.Sensor{ID: "net.internet.download", Name: "Download", Kind: "rate", Unit: "Mbps", Value: r.DownMbps, T: at, Min: sensors.F(0)}, sensors.Sensor{ID: "net.internet.upload", Name: "Upload", Kind: "rate", Unit: "Mbps", Value: r.UpMbps, T: at, Min: sensors.F(0)}, sensors.Sensor{ID: "net.internet.latency", Name: "Latency", Kind: "duration", Unit: "ms", Value: r.LatencyMs, T: at, Min: sensors.F(0)}, sensors.Sensor{ID: "net.internet.jitter", Name: "Jitter", Kind: "duration", Unit: "ms", Value: r.JitterMs, T: at, Min: sensors.F(0)}, sensors.Sensor{ID: "net.internet.tested", Name: "Speed test age", Kind: "duration", Unit: "s", Value: round1(time.Since(r.At).Seconds()), T: at, Min: sensors.F(0)}, ) } func round1(v float64) float64 { return float64(int64(v*10+0.5)) / 10 }