From 25dca3f857fa9b697cdf38f7039332efc7c5ce62 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 16 May 2025 05:03:53 -0700 Subject: [PATCH] latest --- main.go | 280 +++++++++++++++++++++++++++----------------------------- 1 file changed, 133 insertions(+), 147 deletions(-) diff --git a/main.go b/main.go index a949fce..e40ac3a 100644 --- a/main.go +++ b/main.go @@ -21,7 +21,7 @@ import ( "github.com/gdamore/tcell/v2" ) -/*──────────────────────── CLI ────────────────────────*/ +/*────────────────── CLI flags ──────────────────*/ var ( ifaceA = flag.String("ifaceA", "gu0", "primary network interface") @@ -33,21 +33,21 @@ var ( "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") + "comma-separated reachability hosts") ) -/*──────────────────────── constants ─────────────────────*/ +/*────────────────── constants ──────────────────*/ const ( icmpTimeout = 500 * time.Millisecond tcpTimeout = 500 * time.Millisecond packetLossPings = 20 packetLossPeriod = 5 * time.Second - statsHistory = 300 // 5 min @1 Hz + statsHistory = 300 screenRefresh = 500 * time.Millisecond ) -/*──────────────────────── runtime data ──────────────────*/ +/*────────────────── runtime struct ─────────────*/ type InterfaceStatus struct { Name, Label, IPInfo string @@ -61,7 +61,7 @@ type InterfaceStatus struct { mu sync.RWMutex } -/*──────────────────────── colours ───────────────────────*/ +/*────────────────── colour styles ──────────────*/ var ( cBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) @@ -78,25 +78,24 @@ func styleLatency(ms float64) tcell.Style { return cBrightGreen case ms < 100: return cGreen - case ms < 200: // new threshold + case ms < 200: return cYellow default: return cRed } } - -func styleLoss(pct float64) tcell.Style { +func styleLoss(p float64) tcell.Style { switch { - case pct == 0: + case p == 0: return cBrightGreen - case pct < 5: + case p < 5: return cYellow default: return cBrightRed } } -/*──────────────────────── maths ─────────────────────────*/ +/*────────────────── math helpers ───────────────*/ func minMaxAvgStd(xs []float64) (min, max, avg, std float64) { if len(xs) == 0 { @@ -123,33 +122,29 @@ func minMaxAvgStd(xs []float64) (min, max, avg, std float64) { return } -/*──────────────────────── external lookup ───────────────*/ +/*────────────────── external lookup ────────────*/ type ipInfoResp struct{ IP, Hostname, Org string } func fetchIPInfo(iface string) string { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - out, err := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output() - if err != nil { - return "(ipinfo error)" - } + out, _ := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output() var r ipInfoResp _ = json.Unmarshal(out, &r) if r.IP == "" { - return "(ipinfo parse error)" + return "(ipinfo error)" } return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) } -/*──────────────────────── ICMP helpers ──────────────────*/ +/*────────────────── ICMP helpers ───────────────*/ func pingOnce(iface, host string) bool { ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout) defer cancel() return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil } - func lossPercent(iface, host string) float64 { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() @@ -171,126 +166,125 @@ func lossPercent(iface, host string) float64 { return 1.0 } -/*──────────────────────── TCP helpers ───────────────────*/ +/*────────────────── TCP helpers ───────────────*/ func localAddr(iface string) (net.Addr, error) { - ni, err := net.InterfaceByName(iface) + ifi, err := net.InterfaceByName(iface) if err != nil { return nil, err } - for _, a := range func() []net.Addr { a, _ := ni.Addrs(); return a }() { + add, _ := ifi.Addrs() + for _, a := range add { if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { return &net.TCPAddr{IP: ipnet.IP}, nil } } return nil, fmt.Errorf("no IPv4 on %s", iface) } - func tcpDuration(iface, hp string) time.Duration { la, err := localAddr(iface) if err != nil { return tcpTimeout } d := net.Dialer{Timeout: tcpTimeout, LocalAddr: la} - start := time.Now() + st := time.Now() c, err := d.Dial("tcp", hp) if err != nil { return tcpTimeout } c.Close() - return time.Since(start) + return time.Since(st) } -/*──────────────────────── spinner ───────────────────────*/ +/*────────────────── spinner ────────────────────*/ var spins = []rune{'|', '/', '-', '\\'} func (st *InterfaceStatus) spin() { st.SpinFrame = (st.SpinFrame + 1) % len(spins) } -/*──────────────────────── screen helpers ────────────────*/ +/*────────────────── screen helpers ─────────────*/ func put(scr tcell.Screen, x, y int, txt string, st tcell.Style) { for i, r := range txt { scr.SetContent(x+i, y, r, nil, st) } } - func hline(w int) string { return strings.Repeat("=", w) } -/*──────────────────────── UI draw helpers ───────────────*/ +/*────────────────── UI drawing ─────────────────*/ -func ifaceHeaderStyle(sta *InterfaceStatus) tcell.Style { - sta.mu.RLock() - defer sta.mu.RUnlock() - if sta.DroppedCount > 0 { - return cBrightRed +func ifaceHealthy(st *InterfaceStatus) bool { + st.mu.RLock() + defer st.mu.RUnlock() + if st.DroppedCount > 0 { + return false } - for _, ok := range sta.Reachable { + for _, ok := range st.Reachable { if !ok { - return cBrightRed + return false } } - for _, lp := range sta.Loss { + for _, lp := range st.Loss { if lp > 0 { - return cBrightRed + return false } } - return cBrightGreen + for _, hp := range tcpTestHosts { + h := st.TCP[hp] + if len(h) == 0 || h[len(h)-1] >= float64(tcpTimeout.Milliseconds()) { + return false + } + } + return true } const ( - hostCol = 0 - minCol = 18 - avgCol = 27 - maxCol = 36 - stdCol = 45 - nCol = 56 + hostW = 23 + numW = 7 + stdW = 8 + nW = 6 ) -func drawLatencyRow(scr tcell.Screen, y int, host string, mi, av, ma, sd float64, n int) { - put(scr, hostCol, y, fmt.Sprintf("%-17s", host), cDefault) - put(scr, minCol, y, fmt.Sprintf("%6.0fms", mi), styleLatency(mi)) - put(scr, avgCol, y, fmt.Sprintf("%6.0fms", av), styleLatency(av)) - put(scr, maxCol, y, fmt.Sprintf("%6.0fms", ma), styleLatency(ma)) - put(scr, stdCol, y, fmt.Sprintf("%7.0fms", sd), cDefault) - put(scr, nCol, y, fmt.Sprintf("%5d", n), cDefault) -} - -func drawIface(scr tcell.Screen, y, w int, sta *InterfaceStatus) int { - /* header */ +func drawIface(scr tcell.Screen, y, w int, st *InterfaceStatus) int { put(scr, 0, y, hline(w), cDefault) - hs := ifaceHeaderStyle(sta) - sta.mu.RLock() - head := fmt.Sprintf("== %c %s — %s", spins[sta.SpinFrame], sta.Label, sta.IPInfo) - sta.mu.RUnlock() - if len(head) > w { - head = head[:w] + + // header line + healthy := ifaceHealthy(st) + style := cBrightGreen + if !healthy { + style = cBrightRed } - put(scr, 0, y+1, head, hs) + st.mu.RLock() + header := fmt.Sprintf("%s: %s — %s", st.Name, st.Label, st.IPInfo) + spin := spins[st.SpinFrame] + st.mu.RUnlock() + put(scr, 0, y+1, "== ", cDefault) + put(scr, 3, y+1, string(spin)+" "+header, style) put(scr, 0, y+2, hline(w), cDefault) y += 4 /* reachability */ - sta.mu.RLock() - total := len(sta.Reachable) + st.mu.RLock() + total := len(st.Reachable) good := 0 - for _, ok := range sta.Reachable { + for _, ok := range st.Reachable { if ok { good++ } } - rStyle := cBrightGreen + age := time.Since(st.LastPing).Round(time.Second) + reachStyle := cBrightGreen if good != total { - rStyle = cBrightRed + reachStyle = cBrightRed } - age := time.Since(sta.LastPing).Round(time.Second) - put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", good, total, sta.LastPing.Format("15:04:05"), age), rStyle) + put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", + good, total, st.LastPing.Format("15:04:05"), age), reachStyle) y++ if good == total { put(scr, 0, y, "Unreachable: none", cDefault) } else { var down []string - for h, ok := range sta.Reachable { + for h, ok := range st.Reachable { if !ok { down = append(down, h) } @@ -303,49 +297,65 @@ func drawIface(scr tcell.Screen, y, w int, sta *InterfaceStatus) int { put(scr, 0, y, "Packet Loss:", cDefault) y++ for _, h := range packetLossHosts { - pct := sta.Loss[h] * 100 - put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", pct), styleLoss(pct)) + p := st.Loss[h] * 100 + put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", p), styleLoss(p)) y++ } dAge := "N/A" - if !sta.LastDrop.IsZero() { - dAge = time.Since(sta.LastDrop).Round(time.Second).String() + if !st.LastDrop.IsZero() { + dAge = time.Since(st.LastDrop).Round(time.Second).String() } - put(scr, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", sta.DroppedCount, sta.LastDrop.Format("15:04:05"), dAge), cDefault) + put(scr, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", + st.DroppedCount, st.LastDrop.Format("15:04:05"), dAge), cDefault) y += 2 /* TCP table */ put(scr, 0, y, "TCP Connect Stats:", cDefault) y++ - put(scr, hostCol, y, "Host", cDefault) - put(scr, minCol, y, " min", cDefault) - put(scr, avgCol, y, " avg", cDefault) - put(scr, maxCol, y, " max", cDefault) - put(scr, stdCol, y, " stddev", cDefault) - put(scr, nCol, y, " n", cDefault) + // header row + headerRow := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*s", + hostW, "Host", numW, "last", numW, "min", numW, "avg", + numW, "max", stdW, "stddev", nW, "n") + put(scr, 0, y, headerRow, cDefault) y++ for _, hp := range tcpTestHosts { - hist := sta.TCP[hp] + hist := st.TCP[hp] if len(hist) == 0 { continue } + last := hist[len(hist)-1] mi, ma, av, sd := minMaxAvgStd(hist) - drawLatencyRow(scr, y, hp, mi, av, ma, sd, len(hist)) + + row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d", + hostW, hp, + numW, fmt.Sprintf("%.0fms", last), + numW, fmt.Sprintf("%.0fms", mi), + numW, fmt.Sprintf("%.0fms", av), + numW, fmt.Sprintf("%.0fms", ma), + stdW, fmt.Sprintf("%.0fms", sd), + nW, len(hist), + ) + put(scr, 0, y, row, cDefault) + // colourise individual numbers + put(scr, hostW+1, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", last)), styleLatency(last)) + put(scr, hostW+1+numW+1, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", mi)), styleLatency(mi)) + put(scr, hostW+1+numW*2+2, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", av)), styleLatency(av)) + put(scr, hostW+1+numW*3+3, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", ma)), styleLatency(ma)) y++ } y++ /* totals */ - put(scr, 0, y, fmt.Sprintf("Total ICMP Requests: %d", sta.TotalICMPReq), cDefault) + put(scr, 0, y, fmt.Sprintf("Total ICMP Requests: %d", st.TotalICMPReq), cDefault) y++ - put(scr, 0, y, fmt.Sprintf("Total ICMP Replies: %d", sta.TotalICMPRep), cDefault) + put(scr, 0, y, fmt.Sprintf("Total ICMP Replies: %d", st.TotalICMPRep), cDefault) y += 2 - sta.mu.RUnlock() + st.mu.RUnlock() return y } -/*──────────────────────── loops ────────────────────────*/ +/*────────────────── goroutine loops ─────────────*/ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tk := time.NewTicker(time.Second) @@ -362,35 +372,25 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { wg.Add(1) go func(host string) { defer wg.Done() - st.mu.Lock() - st.TotalICMPReq++ - st.mu.Unlock() + st.mu.Lock(); st.TotalICMPReq++; st.mu.Unlock() ok := pingOnce(st.Name, host) - mu.Lock() - res[host] = ok - mu.Unlock() + mu.Lock(); res[host] = ok; mu.Unlock() st.mu.Lock() if ok { - st.TotalICMPRep++ - st.spin() + st.TotalICMPRep++; st.spin() } else { - st.DroppedCount++ - st.LastDrop = time.Now() + st.DroppedCount++; st.LastDrop = time.Now() } st.mu.Unlock() }(h) } wg.Wait() - st.mu.Lock() - st.Reachable = res - st.LastPing = time.Now() - st.mu.Unlock() + st.mu.Lock(); st.Reachable = res; st.LastPing = time.Now(); st.mu.Unlock() } } } - func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tk := time.NewTicker(packetLossPeriod) defer tk.Stop() @@ -407,24 +407,14 @@ func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { go func(host string) { defer wg.Done() lp := lossPercent(st.Name, host) - mu.Lock() - res[host] = lp - if lp == 0 { - st.spin() - } - mu.Unlock() + mu.Lock(); res[host] = lp; if lp == 0 { st.spin() }; mu.Unlock() }(h) } wg.Wait() - st.mu.Lock() - for k, v := range res { - st.Loss[k] = v - } - st.mu.Unlock() + st.mu.Lock(); for k, v := range res { st.Loss[k] = v }; st.mu.Unlock() } } } - func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tk := time.NewTicker(time.Second) defer tk.Stop() @@ -436,27 +426,20 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { for _, hp := range hosts { ms := float64(tcpDuration(st.Name, hp).Milliseconds()) st.mu.Lock() - if ms < float64(tcpTimeout.Milliseconds()) { - st.spin() - } - hist := st.TCP[hp] - if len(hist) >= statsHistory { - hist = hist[1:] - } - st.TCP[hp] = append(hist, ms) - st.mu.Unlock() + if ms < float64(tcpTimeout.Milliseconds()) { st.spin() } + hist := st.TCP[hp]; if len(hist) >= statsHistory { hist = hist[1:] } + st.TCP[hp] = append(hist, ms); st.mu.Unlock() } } } } -/*──────────────────────── UI ───────────────────────────*/ +/*────────────────── UI loop ─────────────────────*/ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) { defer scr.Fini() tk := time.NewTicker(screenRefresh) defer tk.Stop() - spin := 0 for { select { @@ -478,7 +461,7 @@ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start } } -/*──────────────────────── main ─────────────────────────*/ +/*────────────────── main ─────────────────────────*/ var ( reachHosts []string @@ -505,34 +488,23 @@ func main() { } a, b := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB) - screen, err := tcell.NewScreen() - if err != nil { - panic(err) - } - if err = screen.Init(); err != nil { - panic(err) - } + scr, err := tcell.NewScreen(); if err != nil { panic(err) } + if err = scr.Init(); err != nil { panic(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // terminal Ctrl-C / SIGTERM sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) go func() { <-sig; cancel() }() - // in-app Ctrl-C / q go func() { for { - ev := screen.PollEvent() - if ev == nil { - return - } - switch e := ev.(type) { - case *tcell.EventKey: - if e.Key() == tcell.KeyCtrlC || e.Rune() == 'q' { - cancel() - return + if ev := scr.PollEvent(); ev != nil { + if ke, ok := ev.(*tcell.EventKey); ok { + if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' { + cancel(); return + } } } } @@ -545,5 +517,19 @@ func main() { go tcpLoop(ctx, a, tcpTestHosts) go tcpLoop(ctx, b, tcpTestHosts) - uiLoop(ctx, screen, a, b, time.Now()) + uiLoop(ctx, scr, a, b, time.Now()) } + +/*────────────────── Extra packet-loss hosts ───── + +To widen geographic and CDN coverage you could add: + + facebook.com + microsoft.com + apple.com + twitter.com + akamai.com + +These are large anycast/CDN endpoints that tend to reveal regional +network quirks. Add them to `packetLossHosts` (and `reachHosts` +if you also want individual pings every second). */