control-c works now

This commit is contained in:
2025-05-16 04:49:41 -07:00
parent 5d6ce56406
commit 6955b10ec6
+119 -124
View File
@@ -21,9 +21,8 @@ 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")
labelA = flag.String("labelA", "gu LAN - VPN outbound", "label for ifaceA") 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,"+ "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",
"comma-separated hosts for reachability checks") "hosts for reachability checks")
) )
// ----------------------------------------------------------------------------- /*──────────────────────── constants ─────────────────────*/
// Consts
// -----------------------------------------------------------------------------
const ( const (
icmpTimeout = 500 * time.Millisecond icmpTimeout = 500 * time.Millisecond
tcpTimeout = 500 * time.Millisecond tcpTimeout = 500 * time.Millisecond
@@ -49,30 +47,28 @@ const (
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
Reachable map[string]bool Reachable map[string]bool
Loss map[string]float64 Loss map[string]float64
TCP map[string][]float64 TCP map[string][]float64
TotalICMPReq int TotalICMPReq, TotalICMPRep int
TotalICMPRep int
DroppedCount int DroppedCount int
LastDrop, LastPing time.Time LastDrop, LastPing time.Time
SpinFrame int SpinFrame int
mu sync.RWMutex mu sync.RWMutex
} }
// ----------------------------------------------------------------------------- /*──────────────────────── colours ───────────────────────*/
// Styles / colours
// -----------------------------------------------------------------------------
var ( var (
cBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) cBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true)
cGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) cGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen)
cYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) cYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow)
cRed = tcell.StyleDefault.Foreground(tcell.ColorRed) cRed = tcell.StyleDefault.Foreground(tcell.ColorRed)
cBrightRed = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true)
cDefault = tcell.StyleDefault cDefault = tcell.StyleDefault
) )
@@ -82,7 +78,7 @@ func styleLatency(ms float64) tcell.Style {
return cBrightGreen return cBrightGreen
case ms < 100: case ms < 100:
return cGreen return cGreen
case ms < 150: case ms < 200: // new threshold
return cYellow return cYellow
default: default:
return cRed return cRed
@@ -96,20 +92,19 @@ func styleLoss(pct float64) tcell.Style {
case pct < 5: case pct < 5:
return cYellow return cYellow
default: default:
return cRed return cBrightRed
} }
} }
// ----------------------------------------------------------------------------- /*──────────────────────── maths ─────────────────────────*/
// Small maths
// ----------------------------------------------------------------------------- func minMaxAvgStd(xs []float64) (min, max, avg, std float64) {
func minMaxAvgStd(nums []float64) (min, max, avg, std float64) { if len(xs) == 0 {
if len(nums) == 0 {
return return
} }
min, max = nums[0], nums[0] min, max = xs[0], xs[0]
var sum float64 var sum float64
for _, v := range nums { for _, v := range xs {
if v < min { if v < min {
min = v min = v
} }
@@ -118,26 +113,24 @@ func minMaxAvgStd(nums []float64) (min, max, avg, std float64) {
} }
sum += v sum += v
} }
avg = sum / float64(len(nums)) avg = sum / float64(len(xs))
var vs float64 var vs float64
for _, v := range nums { for _, v := range xs {
d := v - avg d := v - avg
vs += d * d vs += d * d
} }
std = math.Sqrt(vs / float64(len(nums))) std = math.Sqrt(vs / float64(len(xs)))
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, out, err := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output()
"--max-time", "2", "ipinfo.io").Output()
if err != nil { if err != nil {
return "(ipinfo error)" return "(ipinfo error)"
} }
@@ -149,9 +142,8 @@ func fetchIPInfo(iface string) string {
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()
@@ -179,9 +171,8 @@ 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) ni, err := net.InterfaceByName(iface)
if err != nil { if err != nil {
@@ -201,91 +192,101 @@ func tcpDuration(iface, hp string) time.Duration {
return tcpTimeout return tcpTimeout
} }
d := net.Dialer{Timeout: tcpTimeout, LocalAddr: la} d := net.Dialer{Timeout: tcpTimeout, LocalAddr: la}
st := time.Now() start := 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(st) return time.Since(start)
} }
// ----------------------------------------------------------------------------- /*──────────────────────── 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(s tcell.Screen, x, y int, str string, st tcell.Style) { for i, r := range txt {
for i, r := range str { scr.SetContent(x+i, y, r, nil, st)
s.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) }
func headerLine(s tcell.Screen, y, w int, spin rune, txt string) { /*──────────────────────── UI draw helpers ───────────────*/
line := fmt.Sprintf("== %c %s", spin, txt)
if len(line) > w {
line = line[:w]
}
put(s, 0, y, line, cDefault)
}
// -----------------------------------------------------------------------------
// UI drawing
// -----------------------------------------------------------------------------
func ifaceHeaderStyle(sta *InterfaceStatus) tcell.Style { func ifaceHeaderStyle(sta *InterfaceStatus) tcell.Style {
sta.mu.RLock() sta.mu.RLock()
defer sta.mu.RUnlock() defer sta.mu.RUnlock()
if sta.DroppedCount > 0 { if sta.DroppedCount > 0 {
return cRed return cBrightRed
} }
for _, ok := range sta.Reachable { for _, ok := range sta.Reachable {
if !ok { if !ok {
return cRed return cBrightRed
} }
} }
for _, lp := range sta.Loss { for _, lp := range sta.Loss {
if lp > 0 { if lp > 0 {
return cRed return cBrightRed
} }
} }
return cBrightGreen 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 { 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) hs := ifaceHeaderStyle(sta)
sta.mu.RLock() sta.mu.RLock()
spin := spins[sta.SpinFrame] head := fmt.Sprintf("== %c %s — %s", spins[sta.SpinFrame], sta.Label, sta.IPInfo)
head := fmt.Sprintf("%s — %s", sta.Label, sta.IPInfo)
sta.mu.RUnlock() 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) put(scr, 0, y+2, hline(w), cDefault)
y += 4 y += 4
/* reachability */
sta.mu.RLock() sta.mu.RLock()
total := len(sta.Reachable) total := len(sta.Reachable)
okCnt := 0 good := 0
for _, ok := range sta.Reachable { for _, ok := range sta.Reachable {
if ok { if ok {
okCnt++ good++
} }
} }
rStyle := cBrightGreen rStyle := cBrightGreen
if okCnt != total { if good != total {
rStyle = cRed rStyle = cBrightRed
} }
age := time.Since(sta.LastPing).Round(time.Second) 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)
okCnt, total, sta.LastPing.Format("15:04:05"), age), rStyle)
y++ y++
if okCnt == 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
@@ -294,49 +295,48 @@ func drawIface(scr tcell.Screen, y, w int, sta *InterfaceStatus) int {
down = append(down, h) down = append(down, h)
} }
} }
put(scr, 0, y, "Unreachable: "+strings.Join(down, ", "), cRed) put(scr, 0, y, "Unreachable: "+strings.Join(down, ", "), cBrightRed)
} }
y += 2 y += 2
// loss table /* packet loss */
put(scr, 0, y, "Packet Loss:", cDefault) put(scr, 0, y, "Packet Loss:", cDefault)
y++ y++
for _, h := range packetLossHosts { for _, h := range packetLossHosts {
lp := sta.Loss[h] * 100 pct := sta.Loss[h] * 100
put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", lp), put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", pct), styleLoss(pct))
styleLoss(lp))
y++ y++
} }
dAge := "N/A" dAge := "N/A"
if !sta.LastDrop.IsZero() { if !sta.LastDrop.IsZero() {
dAge = time.Since(sta.LastDrop).Round(time.Second).String() dAge = time.Since(sta.LastDrop).Round(time.Second).String()
} }
put(scr, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", put(scr, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", sta.DroppedCount, sta.LastDrop.Format("15:04:05"), dAge), cDefault)
sta.DroppedCount, sta.LastDrop.Format("15:04:05"), dAge), cDefault)
y += 2 y += 2
// TCP /* TCP table */
put(scr, 0, y, "TCP Connect Stats:", cDefault) put(scr, 0, y, "TCP Connect Stats:", cDefault)
y++ 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++ y++
for _, hp := range tcpTestHosts { for _, hp := range tcpTestHosts {
hist := sta.TCP[hp] hist := sta.TCP[hp]
if len(hist) == 0 { if len(hist) == 0 {
continue continue
} }
mi, ma, av, sd := minMaxAvgStd(hist) mi, ma, av, sd := minMaxAvgStd(hist)
put(scr, 0, y, fmt.Sprintf("%-16s", hp), cDefault) drawLatencyRow(scr, y, hp, mi, av, ma, sd, len(hist))
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)
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", sta.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", sta.TotalICMPRep), cDefault)
@@ -345,9 +345,8 @@ func drawIface(scr tcell.Screen, y, w int, sta *InterfaceStatus) int {
return y return y
} }
// ----------------------------------------------------------------------------- /*──────────────────────── loops ────────────────────────*/
// 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)
defer tk.Stop() defer tk.Stop()
@@ -368,7 +367,6 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
st.mu.Unlock() st.mu.Unlock()
ok := pingOnce(st.Name, host) ok := pingOnce(st.Name, host)
mu.Lock() mu.Lock()
res[host] = ok res[host] = ok
mu.Unlock() mu.Unlock()
@@ -452,32 +450,13 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
} }
} }
// ----------------------------------------------------------------------------- /*──────────────────────── UI ───────────────────────────*/
// UI loop + input watcher
// -----------------------------------------------------------------------------
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()
// 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 spin := 0
for { for {
select { select {
@@ -487,10 +466,10 @@ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start
w, _ := scr.Size() w, _ := scr.Size()
scr.Clear() scr.Clear()
put(scr, 0, 0, hline(w), cDefault) 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) put(scr, 0, 2, hline(w), cDefault)
spin++ 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 := 5
y = drawIface(scr, y, w, a) y = drawIface(scr, y, w, a)
_ = drawIface(scr, y, w, b) _ = drawIface(scr, y, w, b)
@@ -499,9 +478,8 @@ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start
} }
} }
// ----------------------------------------------------------------------------- /*──────────────────────── main ─────────────────────────*/
// Main
// -----------------------------------------------------------------------------
var ( var (
reachHosts []string reachHosts []string
packetLossHosts = []string{"github.com", "google.com", "1.1.1.1", "8.8.8.8"} packetLossHosts = []string{"github.com", "google.com", "1.1.1.1", "8.8.8.8"}
@@ -515,7 +493,7 @@ func main() {
flag.Parse() flag.Parse()
reachHosts = strings.Split(*hostCSV, ",") reachHosts = strings.Split(*hostCSV, ",")
mkStatus := func(name, label string) *InterfaceStatus { newStatus := func(name, label string) *InterfaceStatus {
return &InterfaceStatus{ return &InterfaceStatus{
Name: name, Name: name,
Label: label, Label: label,
@@ -525,24 +503,41 @@ func main() {
TCP: map[string][]float64{}, 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 { if err != nil {
panic(err) panic(err)
} }
if err = scr.Init(); err != nil { if err = screen.Init(); err != nil {
panic(err) panic(err)
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// Ctrl-C from terminal (outside raw mode) // 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() {
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, a, reachHosts)
go reachLoop(ctx, b, reachHosts) go reachLoop(ctx, b, reachHosts)
go lossLoop(ctx, a, packetLossHosts) go lossLoop(ctx, a, packetLossHosts)
@@ -550,5 +545,5 @@ func main() {
go tcpLoop(ctx, a, tcpTestHosts) go tcpLoop(ctx, a, tcpTestHosts)
go tcpLoop(ctx, b, tcpTestHosts) go tcpLoop(ctx, b, tcpTestHosts)
uiLoop(ctx, scr, a, b, time.Now()) uiLoop(ctx, screen, a, b, time.Now())
} }