diff --git a/main.go b/main.go index 4283128..290c7e8 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ -// netmon – real-time dual-interface network dashboard -// WTFPL – 2025-05-16 sneak@sneak.berlin +// netmon – dual-interface network dashboard (curses) +// WTFPL – 2025-05-16 sneak@sneak.berlin package main import ( @@ -19,56 +19,55 @@ import ( ) //----------------------------------------------------------------------------- -// Configuration flags +// Flags //----------------------------------------------------------------------------- - var ( - ifaceA = flag.String("ifaceA", "gu0", "primary network interface") - labelA = flag.String("labelA", "gu LAN - VPN outbound", "label for ifaceA") - ifaceB = flag.String("ifaceB", "backhaul0", "secondary network interface") - labelB = flag.String("labelB", "Cox cable direct", "label for ifaceB") + ifaceA = flag.String("ifaceA", "gu0", + "primary network interface") + labelA = flag.String("labelA", "gu LAN - VPN outbound", + "label for ifaceA") + ifaceB = flag.String("ifaceB", "backhaul0", + "secondary network interface") + labelB = flag.String("labelB", "Cox cable direct", + "label for ifaceB") hostCSV = flag.String("hosts", - "1.1.1.1,8.8.8.8,8.8.4.4,google.com,github.com,console.aws.amazon.com,"+ - "console.cloud.google.com,fast.com,cloudflare.com,datavi.be", - "comma-separated hosts for reachability checks") + "1.1.1.1,8.8.8.8,8.8.4.4,google.com,github.com,"+ + "console.aws.amazon.com,console.cloud.google.com,"+ + "fast.com,cloudflare.com,datavi.be", + "hosts for reachability checks") ) //----------------------------------------------------------------------------- // Constants //----------------------------------------------------------------------------- - const ( - icmpTimeout = 500 * time.Millisecond // per-ping timeout - tcpTimeout = 500 * time.Millisecond // TCP handshake timeout - packetLossPings = 20 // pings per host for loss calc - packetLossPeriod = 5 * time.Second // how often to recalc loss - statsHistory = 300 // 5 minutes of 1 Hz samples - screenRefresh = 500 * time.Millisecond // spinner / UI cadence + icmpTimeout = 500 * time.Millisecond + tcpTimeout = 500 * time.Millisecond + packetLossPings = 20 + packetLossPeriod = 5 * time.Second + statsHistory = 300 // 5 min of 1 Hz samples + screenRefresh = 500 * time.Millisecond ) //----------------------------------------------------------------------------- -// Runtime data structures +// Data //----------------------------------------------------------------------------- - type InterfaceStatus struct { - Name string - Label string - IPInfo string - - Reachable map[string]bool // last reachability results - Loss map[string]float64// packet-loss % - TCP map[string][]float64 // rolling TCP ms samples - - LastPing time.Time - - mu sync.RWMutex + Name string + Label string + IPInfo string + Reachable map[string]bool + Loss map[string]float64 + TCP map[string][]float64 + LastPing time.Time + SpinFrame int + mu sync.RWMutex } //----------------------------------------------------------------------------- -// Helpers – maths without external deps +// Math helpers (no heavy deps) //----------------------------------------------------------------------------- - func minMaxAvgStd(nums []float64) (min, max, avg, std float64) { if len(nums) == 0 { return @@ -85,22 +84,19 @@ func minMaxAvgStd(nums []float64) (min, max, avg, std float64) { sum += v } avg = sum / float64(len(nums)) - var varSum float64 + var vSum float64 for _, v := range nums { diff := v - avg - varSum += diff * diff + vSum += diff * diff } - std = math.Sqrt(varSum / float64(len(nums))) + std = math.Sqrt(vSum / float64(len(nums))) return } //----------------------------------------------------------------------------- // External look-ups //----------------------------------------------------------------------------- - -type ipInfo struct { - IP, Hostname, Org string -} +type ipInfo struct{ IP, Hostname, Org string } func fetchIPInfo(iface string) string { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -119,21 +115,18 @@ func fetchIPInfo(iface string) string { } //----------------------------------------------------------------------------- -// Ping helpers +// Ping + loss //----------------------------------------------------------------------------- - func pingOnce(iface, host string) bool { ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout) defer cancel() - err := exec.CommandContext(ctx, - "ping", "-I", iface, "-c1", "-W1", host).Run() + err := exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() return err == nil } func lossPercent(iface, host string) float64 { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05", "-c", fmt.Sprint(packetLossPings), "-W1", "-I", iface, host).CombinedOutput() @@ -141,13 +134,12 @@ func lossPercent(iface, host string) float64 { return 1.0 } for _, line := range strings.Split(string(out), "\n") { - if !strings.Contains(line, "packet loss") { - continue - } - for _, f := range strings.Fields(line) { - if strings.HasSuffix(f, "%") { - pct, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64) - return pct / 100.0 + if strings.Contains(line, "packet loss") { + for _, f := range strings.Fields(line) { + if strings.HasSuffix(f, "%") { + p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64) + return p / 100.0 + } } } } @@ -155,18 +147,14 @@ func lossPercent(iface, host string) float64 { } //----------------------------------------------------------------------------- -// TCP helpers +// TCP timing //----------------------------------------------------------------------------- - -func localAddrFor(iface string) (net.Addr, error) { +func localAddr(iface string) (net.Addr, error) { ni, err := net.InterfaceByName(iface) if err != nil { return nil, err } - addrs, err := ni.Addrs() - if err != nil { - return nil, err - } + addrs, _ := ni.Addrs() for _, a := range addrs { if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { return &net.TCPAddr{IP: ipnet.IP}, nil @@ -176,7 +164,7 @@ func localAddrFor(iface string) (net.Addr, error) { } func tcpDuration(iface, hostPort string) time.Duration { - la, err := localAddrFor(iface) + la, err := localAddr(iface) if err != nil { return tcpTimeout } @@ -191,36 +179,44 @@ func tcpDuration(iface, hostPort string) time.Duration { } //----------------------------------------------------------------------------- -// Goroutines – reachability, loss, TCP stats +// Spinners //----------------------------------------------------------------------------- +var spinner = []rune{'|', '/', '-', '\\'} -func reachabilityLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { +func (st *InterfaceStatus) tickSpinner() { + st.SpinFrame = (st.SpinFrame + 1) % len(spinner) +} + +//----------------------------------------------------------------------------- +// Goroutines +//----------------------------------------------------------------------------- +func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tick := time.NewTicker(time.Second) defer tick.Stop() - for { select { case <-ctx.Done(): return case <-tick.C: var wg sync.WaitGroup - results := make(map[string]bool, len(hosts)) + res := make(map[string]bool, len(hosts)) mu := sync.Mutex{} - for _, h := range hosts { wg.Add(1) go func(host string) { defer wg.Done() ok := pingOnce(st.Name, host) mu.Lock() - results[host] = ok + res[host] = ok + if ok { + st.tickSpinner() + } mu.Unlock() }(h) } wg.Wait() - st.mu.Lock() - st.Reachable = results + st.Reachable = res st.LastPing = time.Now() st.mu.Unlock() } @@ -230,30 +226,30 @@ func reachabilityLoop(ctx context.Context, st *InterfaceStatus, hosts []string) func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tick := time.NewTicker(packetLossPeriod) defer tick.Stop() - for { select { case <-ctx.Done(): return case <-tick.C: var wg sync.WaitGroup - results := make(map[string]float64, len(hosts)) + res := make(map[string]float64, len(hosts)) mu := sync.Mutex{} - for _, h := range hosts { wg.Add(1) go func(host string) { defer wg.Done() lp := lossPercent(st.Name, host) mu.Lock() - results[host] = lp + res[host] = lp + if lp < 1.0 { + st.tickSpinner() + } mu.Unlock() }(h) } wg.Wait() - st.mu.Lock() - for k, v := range results { + for k, v := range res { st.Loss[k] = v } st.mu.Unlock() @@ -264,7 +260,6 @@ func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tick := time.NewTicker(time.Second) defer tick.Stop() - for { select { case <-ctx.Done(): @@ -272,11 +267,13 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { case <-tick.C: for _, hp := range hosts { ms := float64(tcpDuration(st.Name, hp).Milliseconds()) - st.mu.Lock() + if ms < float64(tcpTimeout.Milliseconds()) { + st.tickSpinner() + } hist := st.TCP[hp] if len(hist) >= statsHistory { - hist = hist[1:] // drop oldest + hist = hist[1:] } st.TCP[hp] = append(hist, ms) st.mu.Unlock() @@ -286,25 +283,39 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { } //----------------------------------------------------------------------------- -// UI +// UI helpers //----------------------------------------------------------------------------- - -var spin = []rune{'|', '/', '-', '\\'} - -func writeLine(s tcell.Screen, x, y int, str string) { +func write(s tcell.Screen, x, y int, str string) { for i, r := range str { s.SetContent(x+i, y, r, nil, tcell.StyleDefault) } } -func drawSection(s tcell.Screen, x, y, w int, st *InterfaceStatus) { +func drawDivider(s tcell.Screen, y, w int) { + line := strings.Repeat("=", w) + write(s, 0, y, line) +} + +func drawHeaderLine(s tcell.Screen, y, w int, spin rune, txt string) { + line := fmt.Sprintf("== %c %s", spin, txt) + if len(line) > w { + line = line[:w] + } + write(s, 0, y, line) +} + +func drawInterface(s tcell.Screen, y, w int, st *InterfaceStatus) int { + // divider, header, divider + drawDivider(s, y, w) + st.mu.RLock() + spin := spinner[st.SpinFrame] + head := fmt.Sprintf("%s — %s", st.Label, st.IPInfo) + st.mu.RUnlock() + drawHeaderLine(s, y+1, w, spin, head) + drawDivider(s, y+2, w) + y += 4 // skip blank line after + // contents st.mu.RLock() - defer st.mu.RUnlock() - - // header - writeLine(s, x, y, fmt.Sprintf("%s — %s", st.Label, st.IPInfo)) - - // reachability total := len(st.Reachable) rc := 0 var down []string @@ -316,121 +327,117 @@ func drawSection(s tcell.Screen, x, y, w int, st *InterfaceStatus) { } } age := time.Since(st.LastPing).Round(time.Second) - writeLine(s, x, y+1, - fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", - rc, total, st.LastPing.Format("15:04:05"), age)) + write(s, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", + rc, total, st.LastPing.Format("15:04:05"), age)) + y++ if len(down) > 0 { - writeLine(s, x, y+2, "Unreachable: "+strings.Join(down, ", ")) + write(s, 0, y, "Unreachable: "+strings.Join(down, ", ")) } else { - writeLine(s, x, y+2, "Unreachable: none") + write(s, 0, y, "Unreachable: none") } - - // packet loss - row := y + 4 - writeLine(s, x, row, "Packet Loss:") - row++ + y += 2 + write(s, 0, y, "Packet Loss:") + y++ for _, h := range packetLossHosts { lp := st.Loss[h] * 100 - writeLine(s, x, row, fmt.Sprintf("%-16s %.0f%%", h+":", lp)) - row++ + write(s, 0, y, fmt.Sprintf("%-16s %.0f%%", h+":", lp)) + y++ } - - // TCP stats - writeLine(s, x, row, "TCP Connect Stats:") - row++ + y++ + write(s, 0, y, "TCP Connect Stats:") + y++ for _, hp := range tcpTestHosts { samples := st.TCP[hp] if len(samples) == 0 { continue } mi, ma, av, sd := minMaxAvgStd(samples) - writeLine(s, x, row, - fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms", - hp, mi, av, ma, sd)) - row++ + write(s, 0, y, fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms", + hp, mi, av, ma, sd)) + y++ } + st.mu.RUnlock() + return y + 2 // leave a blank line after section } +//----------------------------------------------------------------------------- +// UI main loop +//----------------------------------------------------------------------------- func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus) { defer scr.Fini() - tick := time.NewTicker(screenRefresh) - defer tick.Stop() - frame := 0 - + ticker := time.NewTicker(screenRefresh) + defer ticker.Stop() + topSpin := 0 for { select { case <-ctx.Done(): return - case <-tick.C: + case <-ticker.C: + w, _ := scr.Size() scr.Clear() - w, h := scr.Size() - mid := h / 2 - - ts := time.Now().Format(time.RFC1123Z) - writeLine(scr, 0, 0, fmt.Sprintf("%c %s", spin[frame%len(spin)], ts)) - frame++ - - drawSection(scr, 0, 1, w, a) - drawSection(scr, 0, mid, w, b) - + // top banner + drawDivider(scr, 0, w) + drawHeaderLine(scr, 1, w, spinner[topSpin%len(spinner)], + time.Now().Format(time.RFC1123Z)) + drawDivider(scr, 2, w) + topSpin++ + y := 4 + y = drawInterface(scr, y, w, a) + y = drawInterface(scr, y, w, b) scr.Show() } } } //----------------------------------------------------------------------------- -// Entry +// Main //----------------------------------------------------------------------------- - var ( - reachabilityHosts []string - packetLossHosts = []string{"datavi.be", "google.com", "fast.com", "cloudflare.com", "github.com"} - tcpTestHosts = []string{"google.com:443", "github.com:443", "1.1.1.1:443", "8.8.8.8:53"} + reachHosts []string + packetLossHosts = []string{"datavi.be", "google.com", "fast.com", + "cloudflare.com", "github.com"} + tcpTestHosts = []string{"google.com:443", "github.com:443", + "1.1.1.1:443", "8.8.8.8:53"} ) func main() { flag.Parse() - reachabilityHosts = strings.Split(*hostCSV, ",") + reachHosts = strings.Split(*hostCSV, ",") - // prepare interface status objects - stA := &InterfaceStatus{ + a := &InterfaceStatus{ Name: *ifaceA, Label: *labelA, - Reachable: make(map[string]bool), - Loss: make(map[string]float64), - TCP: make(map[string][]float64), + Reachable: map[string]bool{}, + Loss: map[string]float64{}, + TCP: map[string][]float64{}, IPInfo: fetchIPInfo(*ifaceA), } - stB := &InterfaceStatus{ + b := &InterfaceStatus{ Name: *ifaceB, Label: *labelB, - Reachable: make(map[string]bool), - Loss: make(map[string]float64), - TCP: make(map[string][]float64), + Reachable: map[string]bool{}, + Loss: map[string]float64{}, + TCP: map[string][]float64{}, IPInfo: fetchIPInfo(*ifaceB), } - screen, err := tcell.NewScreen() + scr, err := tcell.NewScreen() if err != nil { panic(err) } - if err := screen.Init(); err != nil { + if err := scr.Init(); err != nil { panic(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // background jobs - go reachabilityLoop(ctx, stA, reachabilityHosts) - go reachabilityLoop(ctx, stB, reachabilityHosts) + go reachLoop(ctx, a, reachHosts) + go reachLoop(ctx, b, reachHosts) + go lossLoop(ctx, a, packetLossHosts) + go lossLoop(ctx, b, packetLossHosts) + go tcpLoop(ctx, a, tcpTestHosts) + go tcpLoop(ctx, b, tcpTestHosts) - go lossLoop(ctx, stA, packetLossHosts) - go lossLoop(ctx, stB, packetLossHosts) - - go tcpLoop(ctx, stA, tcpTestHosts) - go tcpLoop(ctx, stB, tcpTestHosts) - - // UI (blocks) - uiLoop(ctx, screen, stA, stB) + uiLoop(ctx, scr, a, b) }