color!
This commit is contained in:
@@ -9,73 +9,99 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Flags
|
// CLI flags
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
var (
|
var (
|
||||||
ifaceA = flag.String("ifaceA", "gu0",
|
ifaceA = flag.String("ifaceA", "gu0", "primary network interface")
|
||||||
"primary network interface")
|
labelA = flag.String("labelA", "gu LAN - VPN outbound", "label for ifaceA")
|
||||||
labelA = flag.String("labelA", "gu LAN - VPN outbound",
|
ifaceB = flag.String("ifaceB", "backhaul0", "secondary network interface")
|
||||||
"label for ifaceA")
|
labelB = flag.String("labelB", "Cox cable direct", "label for ifaceB")
|
||||||
ifaceB = flag.String("ifaceB", "backhaul0",
|
|
||||||
"secondary network interface")
|
|
||||||
labelB = flag.String("labelB", "Cox cable direct",
|
|
||||||
"label for ifaceB")
|
|
||||||
|
|
||||||
hostCSV = flag.String("hosts",
|
hostCSV = flag.String("hosts",
|
||||||
"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 hosts for reachability checks")
|
||||||
)
|
)
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Constants
|
// Consts
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
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 of 1 Hz samples
|
statsHistory = 300 // 5 min @1 Hz
|
||||||
screenRefresh = 500 * time.Millisecond
|
screenRefresh = 500 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Data structures
|
// Runtime struct
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
type InterfaceStatus struct {
|
type InterfaceStatus struct {
|
||||||
Name string
|
Name, Label, IPInfo string
|
||||||
Label string
|
|
||||||
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 int
|
||||||
TotalICMPRep int
|
TotalICMPRep int
|
||||||
|
|
||||||
DroppedCount int
|
DroppedCount int
|
||||||
LastDrop time.Time
|
LastDrop, LastPing time.Time
|
||||||
|
|
||||||
LastPing time.Time
|
|
||||||
SpinFrame int
|
SpinFrame int
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Math helpers (no heavy deps)
|
// Styles / 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)
|
||||||
|
cDefault = tcell.StyleDefault
|
||||||
|
)
|
||||||
|
|
||||||
|
func styleLatency(ms float64) tcell.Style {
|
||||||
|
switch {
|
||||||
|
case ms < 50:
|
||||||
|
return cBrightGreen
|
||||||
|
case ms < 100:
|
||||||
|
return cGreen
|
||||||
|
case ms < 150:
|
||||||
|
return cYellow
|
||||||
|
default:
|
||||||
|
return cRed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func styleLoss(pct float64) tcell.Style {
|
||||||
|
switch {
|
||||||
|
case pct == 0:
|
||||||
|
return cBrightGreen
|
||||||
|
case pct < 5:
|
||||||
|
return cYellow
|
||||||
|
default:
|
||||||
|
return cRed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Small maths
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
func minMaxAvgStd(nums []float64) (min, max, avg, std float64) {
|
func minMaxAvgStd(nums []float64) (min, max, avg, std float64) {
|
||||||
if len(nums) == 0 {
|
if len(nums) == 0 {
|
||||||
@@ -93,58 +119,56 @@ func minMaxAvgStd(nums []float64) (min, max, avg, std float64) {
|
|||||||
sum += v
|
sum += v
|
||||||
}
|
}
|
||||||
avg = sum / float64(len(nums))
|
avg = sum / float64(len(nums))
|
||||||
var vsum float64
|
var vs float64
|
||||||
for _, v := range nums {
|
for _, v := range nums {
|
||||||
d := v - avg
|
d := v - avg
|
||||||
vsum += d * d
|
vs += d * d
|
||||||
}
|
}
|
||||||
std = math.Sqrt(vsum / float64(len(nums)))
|
std = math.Sqrt(vs / float64(len(nums)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// External look-ups
|
// External lookup
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
type ipInfo 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,
|
out, err := exec.CommandContext(ctx, "curl", "-s", "--interface", iface,
|
||||||
"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)"
|
||||||
}
|
}
|
||||||
var resp ipInfo
|
var r ipInfoResp
|
||||||
_ = json.Unmarshal(out, &resp)
|
_ = json.Unmarshal(out, &r)
|
||||||
if resp.IP == "" {
|
if r.IP == "" {
|
||||||
return "(ipinfo parse error)"
|
return "(ipinfo parse error)"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s [%s] %s", resp.IP, resp.Hostname, resp.Org)
|
return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Ping 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()
|
||||||
err := exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run()
|
return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil
|
||||||
return err == 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()
|
||||||
out, err := exec.CommandContext(ctx,
|
out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05",
|
||||||
"ping", "-q", "-i", "0.05", "-c", fmt.Sprint(packetLossPings),
|
"-c", fmt.Sprint(packetLossPings), "-W1", "-I", iface, host).CombinedOutput()
|
||||||
"-W1", "-I", iface, host).CombinedOutput()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 1.0
|
return 1.0
|
||||||
}
|
}
|
||||||
for _, line := range strings.Split(string(out), "\n") {
|
for _, ln := range strings.Split(string(out), "\n") {
|
||||||
if strings.Contains(line, "packet loss") {
|
if strings.Contains(ln, "packet loss") {
|
||||||
for _, f := range strings.Fields(line) {
|
for _, f := range strings.Fields(ln) {
|
||||||
if strings.HasSuffix(f, "%") {
|
if strings.HasSuffix(f, "%") {
|
||||||
p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64)
|
p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64)
|
||||||
return p / 100.0
|
return p / 100.0
|
||||||
@@ -163,8 +187,7 @@ func localAddr(iface string) (net.Addr, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
addrs, _ := ni.Addrs()
|
for _, a := range func() []net.Addr { a, _ := ni.Addrs(); return a }() {
|
||||||
for _, a := range addrs {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -172,41 +195,167 @@ func localAddr(iface string) (net.Addr, error) {
|
|||||||
return nil, fmt.Errorf("no IPv4 on %s", iface)
|
return nil, fmt.Errorf("no IPv4 on %s", iface)
|
||||||
}
|
}
|
||||||
|
|
||||||
func tcpDuration(iface, hostPort 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()
|
||||||
conn, err := d.Dial("tcp", hostPort)
|
c, err := d.Dial("tcp", hp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tcpTimeout
|
return tcpTimeout
|
||||||
}
|
}
|
||||||
conn.Close()
|
c.Close()
|
||||||
return time.Since(start)
|
return time.Since(st)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Spinner helpers
|
// Spinner
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
var spinner = []rune{'|', '/', '-', '\\'}
|
var spins = []rune{'|', '/', '-', '\\'}
|
||||||
|
|
||||||
func (st *InterfaceStatus) tickSpinner() {
|
func (st *InterfaceStatus) spin() { st.SpinFrame = (st.SpinFrame + 1) % len(spins) }
|
||||||
st.SpinFrame = (st.SpinFrame + 1) % len(spinner)
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Goroutines
|
// UI drawing
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
func ifaceHeaderStyle(sta *InterfaceStatus) tcell.Style {
|
||||||
|
sta.mu.RLock()
|
||||||
|
defer sta.mu.RUnlock()
|
||||||
|
if sta.DroppedCount > 0 {
|
||||||
|
return cRed
|
||||||
|
}
|
||||||
|
for _, ok := range sta.Reachable {
|
||||||
|
if !ok {
|
||||||
|
return cRed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, lp := range sta.Loss {
|
||||||
|
if lp > 0 {
|
||||||
|
return cRed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cBrightGreen
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawIface(scr tcell.Screen, y, w int, sta *InterfaceStatus) int {
|
||||||
|
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)
|
||||||
|
sta.mu.RUnlock()
|
||||||
|
put(scr, 0, y+1, fmt.Sprintf("== %c %s", spin, head), hs)
|
||||||
|
put(scr, 0, y+2, hline(w), cDefault)
|
||||||
|
y += 4
|
||||||
|
|
||||||
|
sta.mu.RLock()
|
||||||
|
total := len(sta.Reachable)
|
||||||
|
okCnt := 0
|
||||||
|
for _, ok := range sta.Reachable {
|
||||||
|
if ok {
|
||||||
|
okCnt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rStyle := cBrightGreen
|
||||||
|
if okCnt != total {
|
||||||
|
rStyle = cRed
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
y++
|
||||||
|
if okCnt == total {
|
||||||
|
put(scr, 0, y, "Unreachable: none", cDefault)
|
||||||
|
} else {
|
||||||
|
var down []string
|
||||||
|
for h, ok := range sta.Reachable {
|
||||||
|
if !ok {
|
||||||
|
down = append(down, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put(scr, 0, y, "Unreachable: "+strings.Join(down, ", "), cRed)
|
||||||
|
}
|
||||||
|
y += 2
|
||||||
|
|
||||||
|
// loss table
|
||||||
|
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))
|
||||||
|
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)
|
||||||
|
y += 2
|
||||||
|
|
||||||
|
// TCP
|
||||||
|
put(scr, 0, y, "TCP Connect Stats:", cDefault)
|
||||||
|
y++
|
||||||
|
put(scr, 0, y, "Host min avg max stddev 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)
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
y++
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
y += 2
|
||||||
|
sta.mu.RUnlock()
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Loops
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
||||||
tick := time.NewTicker(time.Second)
|
tk := time.NewTicker(time.Second)
|
||||||
defer tick.Stop()
|
defer tk.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-tick.C:
|
case <-tk.C:
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
res := make(map[string]bool, len(hosts))
|
res := make(map[string]bool, len(hosts))
|
||||||
mu := sync.Mutex{}
|
mu := sync.Mutex{}
|
||||||
@@ -227,7 +376,7 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
|||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
if ok {
|
if ok {
|
||||||
st.TotalICMPRep++
|
st.TotalICMPRep++
|
||||||
st.tickSpinner()
|
st.spin()
|
||||||
} else {
|
} else {
|
||||||
st.DroppedCount++
|
st.DroppedCount++
|
||||||
st.LastDrop = time.Now()
|
st.LastDrop = time.Now()
|
||||||
@@ -245,13 +394,13 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
||||||
tick := time.NewTicker(packetLossPeriod)
|
tk := time.NewTicker(packetLossPeriod)
|
||||||
defer tick.Stop()
|
defer tk.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-tick.C:
|
case <-tk.C:
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
res := make(map[string]float64, len(hosts))
|
res := make(map[string]float64, len(hosts))
|
||||||
mu := sync.Mutex{}
|
mu := sync.Mutex{}
|
||||||
@@ -262,8 +411,8 @@ func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
|||||||
lp := lossPercent(st.Name, host)
|
lp := lossPercent(st.Name, host)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
res[host] = lp
|
res[host] = lp
|
||||||
if lp < 1.0 {
|
if lp == 0 {
|
||||||
st.tickSpinner()
|
st.spin()
|
||||||
}
|
}
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}(h)
|
}(h)
|
||||||
@@ -279,18 +428,18 @@ func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
||||||
tick := time.NewTicker(time.Second)
|
tk := time.NewTicker(time.Second)
|
||||||
defer tick.Stop()
|
defer tk.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-tick.C:
|
case <-tk.C:
|
||||||
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.tickSpinner()
|
st.spin()
|
||||||
}
|
}
|
||||||
hist := st.TCP[hp]
|
hist := st.TCP[hp]
|
||||||
if len(hist) >= statsHistory {
|
if len(hist) >= statsHistory {
|
||||||
@@ -304,134 +453,47 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// UI helpers
|
// UI loop + input watcher
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
func write(s tcell.Screen, x, y int, str string) {
|
|
||||||
for i, r := range str {
|
|
||||||
s.SetContent(x+i, y, r, nil, tcell.StyleDefault)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func divider(w int) string { return strings.Repeat("=", w) }
|
|
||||||
|
|
||||||
func drawDivider(s tcell.Screen, y, w int) {
|
|
||||||
write(s, 0, y, divider(w))
|
|
||||||
}
|
|
||||||
|
|
||||||
func drawHeader(s tcell.Screen, y, w int, spin rune, txt string) {
|
|
||||||
line := fmt.Sprintf("== %c %s", spin, txt)
|
|
||||||
if len(line) > w {
|
|
||||||
line = line[:w]
|
|
||||||
}
|
|
||||||
write(s, 0, y, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
func drawInterface(s tcell.Screen, y, w int, st *InterfaceStatus) int {
|
|
||||||
// divider, header, divider
|
|
||||||
drawDivider(s, y, w)
|
|
||||||
st.mu.RLock()
|
|
||||||
spin := spinner[st.SpinFrame]
|
|
||||||
head := fmt.Sprintf("%s — %s", st.Label, st.IPInfo)
|
|
||||||
st.mu.RUnlock()
|
|
||||||
drawHeader(s, y+1, w, spin, head)
|
|
||||||
drawDivider(s, y+2, w)
|
|
||||||
y += 4
|
|
||||||
|
|
||||||
st.mu.RLock()
|
|
||||||
// Reachability
|
|
||||||
total := len(st.Reachable)
|
|
||||||
rc := 0
|
|
||||||
var down []string
|
|
||||||
for h, ok := range st.Reachable {
|
|
||||||
if ok {
|
|
||||||
rc++
|
|
||||||
} else {
|
|
||||||
down = append(down, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
age := time.Since(st.LastPing).Round(time.Second)
|
|
||||||
write(s, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)",
|
|
||||||
rc, total, st.LastPing.Format("15:04:05"), age))
|
|
||||||
y++
|
|
||||||
if len(down) > 0 {
|
|
||||||
write(s, 0, y, "Unreachable: "+strings.Join(down, ", "))
|
|
||||||
} else {
|
|
||||||
write(s, 0, y, "Unreachable: none")
|
|
||||||
}
|
|
||||||
y += 2
|
|
||||||
|
|
||||||
// Packet loss
|
|
||||||
write(s, 0, y, "Packet Loss:")
|
|
||||||
y++
|
|
||||||
for _, h := range packetLossHosts {
|
|
||||||
lp := st.Loss[h] * 100
|
|
||||||
write(s, 0, y, fmt.Sprintf("%-16s %.0f%%", h+":", lp))
|
|
||||||
y++
|
|
||||||
}
|
|
||||||
dropAge := "N/A"
|
|
||||||
if !st.LastDrop.IsZero() {
|
|
||||||
dropAge = fmt.Sprintf("%s", time.Since(st.LastDrop).Round(time.Second))
|
|
||||||
}
|
|
||||||
write(s, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)",
|
|
||||||
st.DroppedCount,
|
|
||||||
st.LastDrop.Format("15:04:05"), dropAge))
|
|
||||||
y += 2
|
|
||||||
|
|
||||||
// TCP stats
|
|
||||||
write(s, 0, y, "TCP Connect Stats:")
|
|
||||||
y++
|
|
||||||
for _, hp := range tcpTestHosts {
|
|
||||||
samples := st.TCP[hp]
|
|
||||||
if len(samples) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mi, ma, av, sd := minMaxAvgStd(samples)
|
|
||||||
write(s, 0, y, fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms",
|
|
||||||
hp, mi, av, ma, sd))
|
|
||||||
y++
|
|
||||||
}
|
|
||||||
y++
|
|
||||||
|
|
||||||
// ICMP totals
|
|
||||||
write(s, 0, y, fmt.Sprintf("Total ICMP Requests: %d", st.TotalICMPReq))
|
|
||||||
y++
|
|
||||||
write(s, 0, y, fmt.Sprintf("Total ICMP Replies: %d", st.TotalICMPRep))
|
|
||||||
y += 2
|
|
||||||
|
|
||||||
st.mu.RUnlock()
|
|
||||||
return y
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// 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()
|
||||||
tick := time.NewTicker(screenRefresh)
|
tk := time.NewTicker(screenRefresh)
|
||||||
defer tick.Stop()
|
defer tk.Stop()
|
||||||
topSpin := 0
|
|
||||||
|
|
||||||
|
// 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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-tick.C:
|
case <-tk.C:
|
||||||
w, _ := scr.Size()
|
w, _ := scr.Size()
|
||||||
scr.Clear()
|
scr.Clear()
|
||||||
|
put(scr, 0, 0, hline(w), cDefault)
|
||||||
// top banner
|
headerLine(scr, 1, w, spins[spin%len(spins)], time.Now().Format(time.RFC1123Z))
|
||||||
drawDivider(scr, 0, w)
|
put(scr, 0, 2, hline(w), cDefault)
|
||||||
drawHeader(scr, 1, w, spinner[topSpin%len(spinner)],
|
spin++
|
||||||
time.Now().Format(time.RFC1123Z))
|
put(scr, 0, 3, fmt.Sprintf("Runtime: %s", time.Since(start).Round(time.Second)), cDefault)
|
||||||
drawDivider(scr, 2, w)
|
|
||||||
topSpin++
|
|
||||||
|
|
||||||
write(scr, 0, 3, fmt.Sprintf("Runtime: %s", time.Since(start).Round(time.Second)))
|
|
||||||
|
|
||||||
y := 5
|
y := 5
|
||||||
y = drawInterface(scr, y, w, a)
|
y = drawIface(scr, y, w, a)
|
||||||
_ = drawInterface(scr, y, w, b)
|
_ = drawIface(scr, y, w, b)
|
||||||
|
|
||||||
scr.Show()
|
scr.Show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,13 +504,10 @@ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
var (
|
var (
|
||||||
reachHosts []string
|
reachHosts []string
|
||||||
packetLossHosts = []string{
|
packetLossHosts = []string{"github.com", "google.com", "1.1.1.1", "8.8.8.8"}
|
||||||
"datavi.be", "google.com", "fast.com",
|
|
||||||
"cloudflare.com", "github.com",
|
|
||||||
}
|
|
||||||
tcpTestHosts = []string{
|
tcpTestHosts = []string{
|
||||||
"google.com:443", "github.com:443",
|
"datavi.be:443", "fast.com:443", "cloudflare.com:443",
|
||||||
"1.1.1.1:443", "8.8.8.8:53",
|
"console.aws.amazon.com:443", "console.cloud.google.com:443",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -456,34 +515,34 @@ func main() {
|
|||||||
flag.Parse()
|
flag.Parse()
|
||||||
reachHosts = strings.Split(*hostCSV, ",")
|
reachHosts = strings.Split(*hostCSV, ",")
|
||||||
|
|
||||||
a := &InterfaceStatus{
|
mkStatus := func(name, label string) *InterfaceStatus {
|
||||||
Name: *ifaceA,
|
return &InterfaceStatus{
|
||||||
Label: *labelA,
|
Name: name,
|
||||||
|
Label: label,
|
||||||
|
IPInfo: fetchIPInfo(name),
|
||||||
Reachable: map[string]bool{},
|
Reachable: map[string]bool{},
|
||||||
Loss: map[string]float64{},
|
Loss: map[string]float64{},
|
||||||
TCP: map[string][]float64{},
|
TCP: map[string][]float64{},
|
||||||
IPInfo: fetchIPInfo(*ifaceA),
|
|
||||||
}
|
}
|
||||||
b := &InterfaceStatus{
|
|
||||||
Name: *ifaceB,
|
|
||||||
Label: *labelB,
|
|
||||||
Reachable: map[string]bool{},
|
|
||||||
Loss: map[string]float64{},
|
|
||||||
TCP: map[string][]float64{},
|
|
||||||
IPInfo: fetchIPInfo(*ifaceB),
|
|
||||||
}
|
}
|
||||||
|
a, b := mkStatus(*ifaceA, *labelA), mkStatus(*ifaceB, *labelB)
|
||||||
|
|
||||||
scr, err := tcell.NewScreen()
|
scr, err := tcell.NewScreen()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
if err := scr.Init(); err != nil {
|
if err = scr.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)
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() { <-sig; cancel() }()
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user