This commit is contained in:
2025-05-16 05:03:53 -07:00
parent 6955b10ec6
commit 25dca3f857
+132 -146
View File
@@ -21,7 +21,7 @@ import (
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
) )
/*──────────────────────── CLI ────────────────────────*/ /*────────────────── CLI flags ──────────────────*/
var ( var (
ifaceA = flag.String("ifaceA", "gu0", "primary network interface") 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,"+ "1.1.1.1,8.8.8.8,8.8.4.4,google.com,github.com,"+
"console.aws.amazon.com,console.cloud.google.com,"+ "console.aws.amazon.com,console.cloud.google.com,"+
"fast.com,cloudflare.com,datavi.be", "fast.com,cloudflare.com,datavi.be",
"hosts for reachability checks") "comma-separated reachability hosts")
) )
/*──────────────────────── constants ─────────────────────*/ /*────────────────── constants ──────────────────*/
const ( const (
icmpTimeout = 500 * time.Millisecond icmpTimeout = 500 * time.Millisecond
tcpTimeout = 500 * time.Millisecond tcpTimeout = 500 * time.Millisecond
packetLossPings = 20 packetLossPings = 20
packetLossPeriod = 5 * time.Second packetLossPeriod = 5 * time.Second
statsHistory = 300 // 5 min @1 Hz statsHistory = 300
screenRefresh = 500 * time.Millisecond screenRefresh = 500 * time.Millisecond
) )
/*──────────────────────── runtime data ──────────────────*/ /*────────────────── runtime struct ─────────────*/
type InterfaceStatus struct { type InterfaceStatus struct {
Name, Label, IPInfo string Name, Label, IPInfo string
@@ -61,7 +61,7 @@ type InterfaceStatus struct {
mu sync.RWMutex mu sync.RWMutex
} }
/*──────────────────────── colours ───────────────────────*/ /*────────────────── colour styles ──────────────*/
var ( var (
cBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) cBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true)
@@ -78,25 +78,24 @@ func styleLatency(ms float64) tcell.Style {
return cBrightGreen return cBrightGreen
case ms < 100: case ms < 100:
return cGreen return cGreen
case ms < 200: // new threshold case ms < 200:
return cYellow return cYellow
default: default:
return cRed return cRed
} }
} }
func styleLoss(p float64) tcell.Style {
func styleLoss(pct float64) tcell.Style {
switch { switch {
case pct == 0: case p == 0:
return cBrightGreen return cBrightGreen
case pct < 5: case p < 5:
return cYellow return cYellow
default: default:
return cBrightRed return cBrightRed
} }
} }
/*──────────────────────── maths ─────────────────────────*/ /*────────────────── math helpers ───────────────*/
func minMaxAvgStd(xs []float64) (min, max, avg, std float64) { func minMaxAvgStd(xs []float64) (min, max, avg, std float64) {
if len(xs) == 0 { if len(xs) == 0 {
@@ -123,33 +122,29 @@ func minMaxAvgStd(xs []float64) (min, max, avg, std float64) {
return return
} }
/*──────────────────────── external lookup ───────────────*/ /*────────────────── external lookup ────────────*/
type ipInfoResp struct{ IP, Hostname, Org string } type ipInfoResp struct{ IP, Hostname, Org string }
func fetchIPInfo(iface string) string { func fetchIPInfo(iface string) string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
out, err := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output() out, _ := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output()
if err != nil {
return "(ipinfo error)"
}
var r ipInfoResp var r ipInfoResp
_ = json.Unmarshal(out, &r) _ = json.Unmarshal(out, &r)
if r.IP == "" { if r.IP == "" {
return "(ipinfo parse error)" return "(ipinfo error)"
} }
return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org)
} }
/*──────────────────────── ICMP helpers ──────────────────*/ /*────────────────── ICMP helpers ───────────────*/
func pingOnce(iface, host string) bool { func pingOnce(iface, host string) bool {
ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout) ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout)
defer cancel() defer cancel()
return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil
} }
func lossPercent(iface, host string) float64 { func lossPercent(iface, host string) float64 {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
@@ -171,126 +166,125 @@ func lossPercent(iface, host string) float64 {
return 1.0 return 1.0
} }
/*──────────────────────── TCP helpers ───────────────────*/ /*────────────────── TCP helpers ───────────────*/
func localAddr(iface string) (net.Addr, error) { func localAddr(iface string) (net.Addr, error) {
ni, err := net.InterfaceByName(iface) ifi, err := net.InterfaceByName(iface)
if err != nil { if err != nil {
return nil, err 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 { if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil {
return &net.TCPAddr{IP: ipnet.IP}, nil return &net.TCPAddr{IP: ipnet.IP}, nil
} }
} }
return nil, fmt.Errorf("no IPv4 on %s", iface) return nil, fmt.Errorf("no IPv4 on %s", iface)
} }
func tcpDuration(iface, hp string) time.Duration { func tcpDuration(iface, hp string) time.Duration {
la, err := localAddr(iface) la, err := localAddr(iface)
if err != nil { if err != nil {
return tcpTimeout return tcpTimeout
} }
d := net.Dialer{Timeout: tcpTimeout, LocalAddr: la} d := net.Dialer{Timeout: tcpTimeout, LocalAddr: la}
start := time.Now() st := time.Now()
c, err := d.Dial("tcp", hp) c, err := d.Dial("tcp", hp)
if err != nil { if err != nil {
return tcpTimeout return tcpTimeout
} }
c.Close() c.Close()
return time.Since(start) return time.Since(st)
} }
/*──────────────────────── spinner ───────────────────────*/ /*────────────────── spinner ────────────────────*/
var spins = []rune{'|', '/', '-', '\\'} var spins = []rune{'|', '/', '-', '\\'}
func (st *InterfaceStatus) spin() { st.SpinFrame = (st.SpinFrame + 1) % len(spins) } 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) { func put(scr tcell.Screen, x, y int, txt string, st tcell.Style) {
for i, r := range txt { for i, r := range txt {
scr.SetContent(x+i, y, r, nil, st) scr.SetContent(x+i, y, r, nil, st)
} }
} }
func hline(w int) string { return strings.Repeat("=", w) } func hline(w int) string { return strings.Repeat("=", w) }
/*──────────────────────── UI draw helpers ───────────────*/ /*────────────────── UI drawing ─────────────────*/
func ifaceHeaderStyle(sta *InterfaceStatus) tcell.Style { func ifaceHealthy(st *InterfaceStatus) bool {
sta.mu.RLock() st.mu.RLock()
defer sta.mu.RUnlock() defer st.mu.RUnlock()
if sta.DroppedCount > 0 { if st.DroppedCount > 0 {
return cBrightRed return false
} }
for _, ok := range sta.Reachable { for _, ok := range st.Reachable {
if !ok { if !ok {
return cBrightRed return false
} }
} }
for _, lp := range sta.Loss { for _, lp := range st.Loss {
if lp > 0 { 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 ( const (
hostCol = 0 hostW = 23
minCol = 18 numW = 7
avgCol = 27 stdW = 8
maxCol = 36 nW = 6
stdCol = 45
nCol = 56
) )
func drawLatencyRow(scr tcell.Screen, y int, host string, mi, av, ma, sd float64, n int) { func drawIface(scr tcell.Screen, y, w int, st *InterfaceStatus) 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) put(scr, 0, y, hline(w), cDefault)
hs := ifaceHeaderStyle(sta)
sta.mu.RLock() // header line
head := fmt.Sprintf("== %c %s — %s", spins[sta.SpinFrame], sta.Label, sta.IPInfo) healthy := ifaceHealthy(st)
sta.mu.RUnlock() style := cBrightGreen
if len(head) > w { if !healthy {
head = head[:w] 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) put(scr, 0, y+2, hline(w), cDefault)
y += 4 y += 4
/* reachability */ /* reachability */
sta.mu.RLock() st.mu.RLock()
total := len(sta.Reachable) total := len(st.Reachable)
good := 0 good := 0
for _, ok := range sta.Reachable { for _, ok := range st.Reachable {
if ok { if ok {
good++ good++
} }
} }
rStyle := cBrightGreen age := time.Since(st.LastPing).Round(time.Second)
reachStyle := cBrightGreen
if good != total { 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)",
put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", good, total, sta.LastPing.Format("15:04:05"), age), rStyle) good, total, st.LastPing.Format("15:04:05"), age), reachStyle)
y++ y++
if good == total { if good == total {
put(scr, 0, y, "Unreachable: none", cDefault) put(scr, 0, y, "Unreachable: none", cDefault)
} else { } else {
var down []string var down []string
for h, ok := range sta.Reachable { for h, ok := range st.Reachable {
if !ok { if !ok {
down = append(down, h) 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) put(scr, 0, y, "Packet Loss:", cDefault)
y++ y++
for _, h := range packetLossHosts { for _, h := range packetLossHosts {
pct := sta.Loss[h] * 100 p := st.Loss[h] * 100
put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", pct), styleLoss(pct)) put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", p), styleLoss(p))
y++ y++
} }
dAge := "N/A" dAge := "N/A"
if !sta.LastDrop.IsZero() { if !st.LastDrop.IsZero() {
dAge = time.Since(sta.LastDrop).Round(time.Second).String() 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 y += 2
/* TCP table */ /* TCP table */
put(scr, 0, y, "TCP Connect Stats:", cDefault) put(scr, 0, y, "TCP Connect Stats:", cDefault)
y++ y++
put(scr, hostCol, y, "Host", cDefault) // header row
put(scr, minCol, y, " min", cDefault) headerRow := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*s",
put(scr, avgCol, y, " avg", cDefault) hostW, "Host", numW, "last", numW, "min", numW, "avg",
put(scr, maxCol, y, " max", cDefault) numW, "max", stdW, "stddev", nW, "n")
put(scr, stdCol, y, " stddev", cDefault) put(scr, 0, y, headerRow, cDefault)
put(scr, nCol, y, " n", cDefault)
y++ y++
for _, hp := range tcpTestHosts { for _, hp := range tcpTestHosts {
hist := sta.TCP[hp] hist := st.TCP[hp]
if len(hist) == 0 { if len(hist) == 0 {
continue continue
} }
last := hist[len(hist)-1]
mi, ma, av, sd := minMaxAvgStd(hist) 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++
} }
y++ y++
/* totals */ /* 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++ 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 y += 2
sta.mu.RUnlock() st.mu.RUnlock()
return y return y
} }
/*──────────────────────── loops ────────────────────────*/ /*────────────────── goroutine loops ─────────────*/
func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
tk := time.NewTicker(time.Second) tk := time.NewTicker(time.Second)
@@ -362,35 +372,25 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
wg.Add(1) wg.Add(1)
go func(host string) { go func(host string) {
defer wg.Done() defer wg.Done()
st.mu.Lock() st.mu.Lock(); st.TotalICMPReq++; st.mu.Unlock()
st.TotalICMPReq++
st.mu.Unlock()
ok := pingOnce(st.Name, host) ok := pingOnce(st.Name, host)
mu.Lock() mu.Lock(); res[host] = ok; mu.Unlock()
res[host] = ok
mu.Unlock()
st.mu.Lock() st.mu.Lock()
if ok { if ok {
st.TotalICMPRep++ st.TotalICMPRep++; st.spin()
st.spin()
} else { } else {
st.DroppedCount++ st.DroppedCount++; st.LastDrop = time.Now()
st.LastDrop = time.Now()
} }
st.mu.Unlock() st.mu.Unlock()
}(h) }(h)
} }
wg.Wait() wg.Wait()
st.mu.Lock() st.mu.Lock(); st.Reachable = res; st.LastPing = time.Now(); st.mu.Unlock()
st.Reachable = res
st.LastPing = time.Now()
st.mu.Unlock()
} }
} }
} }
func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
tk := time.NewTicker(packetLossPeriod) tk := time.NewTicker(packetLossPeriod)
defer tk.Stop() defer tk.Stop()
@@ -407,24 +407,14 @@ func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
go func(host string) { go func(host string) {
defer wg.Done() defer wg.Done()
lp := lossPercent(st.Name, host) lp := lossPercent(st.Name, host)
mu.Lock() mu.Lock(); res[host] = lp; if lp == 0 { st.spin() }; mu.Unlock()
res[host] = lp
if lp == 0 {
st.spin()
}
mu.Unlock()
}(h) }(h)
} }
wg.Wait() wg.Wait()
st.mu.Lock() st.mu.Lock(); for k, v := range res { st.Loss[k] = v }; st.mu.Unlock()
for k, v := range res {
st.Loss[k] = v
}
st.mu.Unlock()
} }
} }
} }
func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
tk := time.NewTicker(time.Second) tk := time.NewTicker(time.Second)
defer tk.Stop() defer tk.Stop()
@@ -436,27 +426,20 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
for _, hp := range hosts { for _, hp := range hosts {
ms := float64(tcpDuration(st.Name, hp).Milliseconds()) ms := float64(tcpDuration(st.Name, hp).Milliseconds())
st.mu.Lock() st.mu.Lock()
if ms < float64(tcpTimeout.Milliseconds()) { if ms < float64(tcpTimeout.Milliseconds()) { st.spin() }
st.spin() hist := st.TCP[hp]; if len(hist) >= statsHistory { hist = hist[1:] }
} st.TCP[hp] = append(hist, ms); st.mu.Unlock()
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) { func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) {
defer scr.Fini() defer scr.Fini()
tk := time.NewTicker(screenRefresh) tk := time.NewTicker(screenRefresh)
defer tk.Stop() defer tk.Stop()
spin := 0 spin := 0
for { for {
select { select {
@@ -478,7 +461,7 @@ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start
} }
} }
/*──────────────────────── main ─────────────────────────*/ /*────────────────── main ─────────────────────────*/
var ( var (
reachHosts []string reachHosts []string
@@ -505,34 +488,23 @@ func main() {
} }
a, b := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB) a, b := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB)
screen, err := tcell.NewScreen() scr, err := tcell.NewScreen(); if err != nil { panic(err) }
if err != nil { if err = scr.Init(); err != nil { panic(err) }
panic(err)
}
if err = screen.Init(); err != nil {
panic(err)
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// terminal Ctrl-C / SIGTERM
sig := make(chan os.Signal, 1) sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM) signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() { <-sig; cancel() }() go func() { <-sig; cancel() }()
// in-app Ctrl-C / q
go func() { go func() {
for { for {
ev := screen.PollEvent() if ev := scr.PollEvent(); ev != nil {
if ev == nil { if ke, ok := ev.(*tcell.EventKey); ok {
return if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' {
cancel(); return
} }
switch e := ev.(type) {
case *tcell.EventKey:
if e.Key() == tcell.KeyCtrlC || e.Rune() == 'q' {
cancel()
return
} }
} }
} }
@@ -545,5 +517,19 @@ func main() {
go tcpLoop(ctx, a, tcpTestHosts) go tcpLoop(ctx, a, tcpTestHosts)
go tcpLoop(ctx, b, 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). */