diff --git a/main.go b/main.go index 4c695e1..a949fce 100644 --- a/main.go +++ b/main.go @@ -21,9 +21,8 @@ import ( "github.com/gdamore/tcell/v2" ) -// ----------------------------------------------------------------------------- -// CLI flags -// ----------------------------------------------------------------------------- +/*──────────────────────── CLI ────────────────────────*/ + var ( ifaceA = flag.String("ifaceA", "gu0", "primary network interface") labelA = flag.String("labelA", "gu LAN - VPN outbound", "label for ifaceA") @@ -34,12 +33,11 @@ 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", - "comma-separated hosts for reachability checks") + "hosts for reachability checks") ) -// ----------------------------------------------------------------------------- -// Consts -// ----------------------------------------------------------------------------- +/*──────────────────────── constants ─────────────────────*/ + const ( icmpTimeout = 500 * time.Millisecond tcpTimeout = 500 * time.Millisecond @@ -49,30 +47,28 @@ const ( screenRefresh = 500 * time.Millisecond ) -// ----------------------------------------------------------------------------- -// Runtime struct -// ----------------------------------------------------------------------------- +/*──────────────────────── runtime data ──────────────────*/ + type InterfaceStatus struct { - Name, Label, IPInfo string - Reachable map[string]bool - Loss map[string]float64 - TCP map[string][]float64 - TotalICMPReq int - TotalICMPRep int - DroppedCount int - LastDrop, LastPing time.Time - SpinFrame int - mu sync.RWMutex + Name, Label, IPInfo string + Reachable map[string]bool + Loss map[string]float64 + TCP map[string][]float64 + TotalICMPReq, TotalICMPRep int + DroppedCount int + LastDrop, LastPing time.Time + SpinFrame int + mu sync.RWMutex } -// ----------------------------------------------------------------------------- -// Styles / colours -// ----------------------------------------------------------------------------- +/*──────────────────────── colours ───────────────────────*/ + var ( cBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) cGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) cYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) cRed = tcell.StyleDefault.Foreground(tcell.ColorRed) + cBrightRed = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) cDefault = tcell.StyleDefault ) @@ -82,7 +78,7 @@ func styleLatency(ms float64) tcell.Style { return cBrightGreen case ms < 100: return cGreen - case ms < 150: + case ms < 200: // new threshold return cYellow default: return cRed @@ -96,20 +92,19 @@ func styleLoss(pct float64) tcell.Style { case pct < 5: return cYellow default: - return cRed + return cBrightRed } } -// ----------------------------------------------------------------------------- -// Small maths -// ----------------------------------------------------------------------------- -func minMaxAvgStd(nums []float64) (min, max, avg, std float64) { - if len(nums) == 0 { +/*──────────────────────── maths ─────────────────────────*/ + +func minMaxAvgStd(xs []float64) (min, max, avg, std float64) { + if len(xs) == 0 { return } - min, max = nums[0], nums[0] + min, max = xs[0], xs[0] var sum float64 - for _, v := range nums { + for _, v := range xs { if v < min { min = v } @@ -118,26 +113,24 @@ func minMaxAvgStd(nums []float64) (min, max, avg, std float64) { } sum += v } - avg = sum / float64(len(nums)) + avg = sum / float64(len(xs)) var vs float64 - for _, v := range nums { + for _, v := range xs { d := v - avg vs += d * d } - std = math.Sqrt(vs / float64(len(nums))) + std = math.Sqrt(vs / float64(len(xs))) 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() + out, err := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output() if err != nil { return "(ipinfo error)" } @@ -149,9 +142,8 @@ func fetchIPInfo(iface string) string { 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() @@ -179,9 +171,8 @@ 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) if err != nil { @@ -201,91 +192,101 @@ func tcpDuration(iface, hp string) time.Duration { return tcpTimeout } d := net.Dialer{Timeout: tcpTimeout, LocalAddr: la} - st := time.Now() + start := time.Now() c, err := d.Dial("tcp", hp) if err != nil { return tcpTimeout } c.Close() - return time.Since(st) + return time.Since(start) } -// ----------------------------------------------------------------------------- -// Spinner -// ----------------------------------------------------------------------------- +/*──────────────────────── spinner ───────────────────────*/ + var spins = []rune{'|', '/', '-', '\\'} func (st *InterfaceStatus) spin() { st.SpinFrame = (st.SpinFrame + 1) % len(spins) } -// ----------------------------------------------------------------------------- -// Screen helpers -// ----------------------------------------------------------------------------- -func put(s tcell.Screen, x, y int, str string, st tcell.Style) { - for i, r := range str { - s.SetContent(x+i, y, r, nil, st) +/*──────────────────────── 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) } -func headerLine(s tcell.Screen, y, w int, spin rune, txt string) { - line := fmt.Sprintf("== %c %s", spin, txt) - if len(line) > w { - line = line[:w] - } - put(s, 0, y, line, cDefault) -} +/*──────────────────────── UI draw helpers ───────────────*/ -// ----------------------------------------------------------------------------- -// UI drawing -// ----------------------------------------------------------------------------- func ifaceHeaderStyle(sta *InterfaceStatus) tcell.Style { sta.mu.RLock() defer sta.mu.RUnlock() if sta.DroppedCount > 0 { - return cRed + return cBrightRed } for _, ok := range sta.Reachable { if !ok { - return cRed + return cBrightRed } } for _, lp := range sta.Loss { if lp > 0 { - return cRed + return cBrightRed } } return cBrightGreen } +const ( + hostCol = 0 + minCol = 18 + avgCol = 27 + maxCol = 36 + stdCol = 45 + nCol = 56 +) + +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 */ put(scr, 0, y, hline(w), cDefault) hs := ifaceHeaderStyle(sta) sta.mu.RLock() - spin := spins[sta.SpinFrame] - head := fmt.Sprintf("%s — %s", sta.Label, sta.IPInfo) + head := fmt.Sprintf("== %c %s — %s", spins[sta.SpinFrame], sta.Label, sta.IPInfo) sta.mu.RUnlock() - put(scr, 0, y+1, fmt.Sprintf("== %c %s", spin, head), hs) + if len(head) > w { + head = head[:w] + } + put(scr, 0, y+1, head, hs) put(scr, 0, y+2, hline(w), cDefault) y += 4 + /* reachability */ sta.mu.RLock() total := len(sta.Reachable) - okCnt := 0 + good := 0 for _, ok := range sta.Reachable { if ok { - okCnt++ + good++ } } rStyle := cBrightGreen - if okCnt != total { - rStyle = cRed + if good != total { + rStyle = cBrightRed } age := time.Since(sta.LastPing).Round(time.Second) - put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", - okCnt, total, sta.LastPing.Format("15:04:05"), age), rStyle) + put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", good, total, sta.LastPing.Format("15:04:05"), age), rStyle) y++ - if okCnt == total { + if good == total { put(scr, 0, y, "Unreachable: none", cDefault) } else { var down []string @@ -294,49 +295,48 @@ func drawIface(scr tcell.Screen, y, w int, sta *InterfaceStatus) int { down = append(down, h) } } - put(scr, 0, y, "Unreachable: "+strings.Join(down, ", "), cRed) + put(scr, 0, y, "Unreachable: "+strings.Join(down, ", "), cBrightRed) } y += 2 - // loss table + /* packet loss */ put(scr, 0, y, "Packet Loss:", cDefault) y++ for _, h := range packetLossHosts { - lp := sta.Loss[h] * 100 - put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", lp), - styleLoss(lp)) + pct := sta.Loss[h] * 100 + put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", pct), styleLoss(pct)) y++ } dAge := "N/A" if !sta.LastDrop.IsZero() { dAge = time.Since(sta.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)", sta.DroppedCount, sta.LastDrop.Format("15:04:05"), dAge), cDefault) y += 2 - // TCP + /* TCP table */ put(scr, 0, y, "TCP Connect Stats:", cDefault) y++ - put(scr, 0, y, "Host min avg max stddev n", cDefault) + 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) y++ + for _, hp := range tcpTestHosts { hist := sta.TCP[hp] if len(hist) == 0 { continue } mi, ma, av, sd := minMaxAvgStd(hist) - put(scr, 0, y, fmt.Sprintf("%-16s", hp), cDefault) - put(scr, 17, y, fmt.Sprintf("%4.0fms", mi), styleLatency(mi)) - put(scr, 24, y, fmt.Sprintf("%4.0fms", av), styleLatency(av)) - put(scr, 31, y, fmt.Sprintf("%4.0fms", ma), styleLatency(ma)) - put(scr, 38, y, fmt.Sprintf("%6.0fms", sd), cDefault) - put(scr, 46, y, fmt.Sprintf("%5d", len(hist)), cDefault) + drawLatencyRow(scr, y, hp, mi, av, ma, sd, len(hist)) y++ } y++ - // totals + /* totals */ put(scr, 0, y, fmt.Sprintf("Total ICMP Requests: %d", sta.TotalICMPReq), cDefault) y++ put(scr, 0, y, fmt.Sprintf("Total ICMP Replies: %d", sta.TotalICMPRep), cDefault) @@ -345,9 +345,8 @@ func drawIface(scr tcell.Screen, y, w int, sta *InterfaceStatus) int { return y } -// ----------------------------------------------------------------------------- -// Loops -// ----------------------------------------------------------------------------- +/*──────────────────────── loops ────────────────────────*/ + func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { tk := time.NewTicker(time.Second) defer tk.Stop() @@ -368,7 +367,6 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { st.mu.Unlock() ok := pingOnce(st.Name, host) - mu.Lock() res[host] = ok mu.Unlock() @@ -452,32 +450,13 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { } } -// ----------------------------------------------------------------------------- -// UI loop + input watcher -// ----------------------------------------------------------------------------- +/*──────────────────────── UI ───────────────────────────*/ + func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) { defer scr.Fini() tk := time.NewTicker(screenRefresh) defer tk.Stop() - // keyboard watcher (Ctrl-C or q) - go func() { - for { - ev := scr.PollEvent() - switch v := ev.(type) { - case *tcell.EventKey: - if v.Key() == tcell.KeyCtrlC || (v.Rune() == 'q') { - // cancel context -> exit - if cancel := ctx.Done(); cancel != nil { - // nothing - } - } - case nil: - return - } - } - }() - spin := 0 for { select { @@ -487,10 +466,10 @@ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start w, _ := scr.Size() scr.Clear() put(scr, 0, 0, hline(w), cDefault) - headerLine(scr, 1, w, spins[spin%len(spins)], time.Now().Format(time.RFC1123Z)) + put(scr, 0, 1, fmt.Sprintf("== %c %s", spins[spin%len(spins)], time.Now().Format(time.RFC1123Z)), cDefault) put(scr, 0, 2, hline(w), cDefault) spin++ - put(scr, 0, 3, fmt.Sprintf("Runtime: %s", time.Since(start).Round(time.Second)), cDefault) + put(scr, 0, 3, "Runtime: "+time.Since(start).Round(time.Second).String(), cDefault) y := 5 y = drawIface(scr, y, w, a) _ = drawIface(scr, y, w, b) @@ -499,9 +478,8 @@ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start } } -// ----------------------------------------------------------------------------- -// Main -// ----------------------------------------------------------------------------- +/*──────────────────────── main ─────────────────────────*/ + var ( reachHosts []string packetLossHosts = []string{"github.com", "google.com", "1.1.1.1", "8.8.8.8"} @@ -515,7 +493,7 @@ func main() { flag.Parse() reachHosts = strings.Split(*hostCSV, ",") - mkStatus := func(name, label string) *InterfaceStatus { + newStatus := func(name, label string) *InterfaceStatus { return &InterfaceStatus{ Name: name, Label: label, @@ -525,24 +503,41 @@ func main() { TCP: map[string][]float64{}, } } - a, b := mkStatus(*ifaceA, *labelA), mkStatus(*ifaceB, *labelB) + a, b := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB) - scr, err := tcell.NewScreen() + screen, err := tcell.NewScreen() if err != nil { panic(err) } - if err = scr.Init(); err != nil { + if err = screen.Init(); err != nil { panic(err) } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Ctrl-C from terminal (outside raw mode) + // 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 + } + } + } + }() + go reachLoop(ctx, a, reachHosts) go reachLoop(ctx, b, reachHosts) go lossLoop(ctx, a, packetLossHosts) @@ -550,5 +545,5 @@ func main() { go tcpLoop(ctx, a, tcpTestHosts) go tcpLoop(ctx, b, tcpTestHosts) - uiLoop(ctx, scr, a, b, time.Now()) + uiLoop(ctx, screen, a, b, time.Now()) }