control-c works now
This commit is contained in:
@@ -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
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user