823 lines
21 KiB
Go
823 lines
21 KiB
Go
//go:build linux
|
||
// +build linux
|
||
|
||
// netmon – dual-interface network dashboard (curses)
|
||
// WTFPL – 2025-05-16 sneak@sneak.berlin
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"flag"
|
||
"fmt"
|
||
"math"
|
||
"net"
|
||
"os"
|
||
"os/exec"
|
||
"os/signal"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
tcell "github.com/gdamore/tcell/v2"
|
||
)
|
||
|
||
/*────────────────── CLI flags ──────────────────*/
|
||
|
||
var (
|
||
ifaceA = flag.String("ifaceA", "gu0", "primary network interface")
|
||
labelA = flag.String("labelA", "gu LAN - VPN outbound", "label for ifaceA")
|
||
ifaceB = flag.String("ifaceB", "backhaul0", "secondary network interface")
|
||
labelB = flag.String("labelB", "Cox cable direct", "label for ifaceB")
|
||
|
||
hostCSV = flag.String("hosts",
|
||
"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 reachability hosts")
|
||
|
||
logFile = flag.String("logfile", "/tmp/rtnetmon.log", "path to log file")
|
||
)
|
||
|
||
/*────────────────── constants ──────────────────*/
|
||
|
||
const (
|
||
icmpTimeout = 500 * time.Millisecond
|
||
tcpTimeout = 500 * time.Millisecond
|
||
packetLossPings = 20
|
||
packetLossPeriod = 5 * time.Second
|
||
statsHistory = 300
|
||
screenRefresh = 500 * time.Millisecond // Still used as backup refresh rate
|
||
|
||
// Unicode characters for the meter
|
||
greenDot = '🟢' // Unicode green circle
|
||
redDot = '🔴' // Unicode red circle
|
||
emptyDot = '⚪' // Unicode white circle
|
||
meterWidth = 6 // Fixed width of the meter
|
||
maxMeterValue = 10 // Maximum value for the meter
|
||
)
|
||
|
||
/*────────────────── runtime struct ─────────────*/
|
||
|
||
type InterfaceStatus struct {
|
||
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
|
||
MeterValue int // Current value for the packet loss meter
|
||
mu sync.RWMutex
|
||
}
|
||
|
||
/*────────────────── colour styles ──────────────*/
|
||
|
||
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
|
||
)
|
||
|
||
func styleLatency(ms float64) tcell.Style {
|
||
switch {
|
||
case ms < 50:
|
||
return cBrightGreen
|
||
case ms < 100:
|
||
return cGreen
|
||
case ms < 200:
|
||
return cYellow
|
||
default:
|
||
return cRed
|
||
}
|
||
}
|
||
func styleLoss(p float64) tcell.Style {
|
||
switch {
|
||
case p == 0:
|
||
return cBrightGreen
|
||
case p < 5:
|
||
return cYellow
|
||
default:
|
||
return cBrightRed
|
||
}
|
||
}
|
||
|
||
/*────────────────── math helpers ───────────────*/
|
||
|
||
func minMaxAvgStd(xs []float64) (min, max, avg, std float64) {
|
||
if len(xs) == 0 {
|
||
return
|
||
}
|
||
min, max = xs[0], xs[0]
|
||
var sum float64
|
||
for _, v := range xs {
|
||
if v < min {
|
||
min = v
|
||
}
|
||
if v > max {
|
||
max = v
|
||
}
|
||
sum += v
|
||
}
|
||
avg = sum / float64(len(xs))
|
||
var vs float64
|
||
for _, v := range xs {
|
||
d := v - avg
|
||
vs += d * d
|
||
}
|
||
std = math.Sqrt(vs / float64(len(xs)))
|
||
return
|
||
}
|
||
|
||
/*────────────────── 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, _ := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output()
|
||
var r ipInfoResp
|
||
_ = json.Unmarshal(out, &r)
|
||
if r.IP == "" {
|
||
return "(ipinfo error)"
|
||
}
|
||
return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org)
|
||
}
|
||
|
||
/*────────────────── ICMP helpers ───────────────*/
|
||
|
||
func pingOnce(iface, host string) bool {
|
||
ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout)
|
||
defer cancel()
|
||
return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil
|
||
}
|
||
func lossPercent(iface, host string) float64 {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||
defer cancel()
|
||
out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05",
|
||
"-c", fmt.Sprint(packetLossPings), "-W1", "-I", iface, host).CombinedOutput()
|
||
if err != nil {
|
||
return 1.0
|
||
}
|
||
for _, ln := range strings.Split(string(out), "\n") {
|
||
if strings.Contains(ln, "packet loss") {
|
||
for _, f := range strings.Fields(ln) {
|
||
if strings.HasSuffix(f, "%") {
|
||
p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64)
|
||
return p / 100.0
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return 1.0
|
||
}
|
||
|
||
/*────────────────── TCP helpers ───────────────*/
|
||
|
||
func localAddr(iface string) (net.Addr, error) {
|
||
ifi, err := net.InterfaceByName(iface)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
add, _ := ifi.Addrs()
|
||
for _, a := range add {
|
||
if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil {
|
||
return &net.TCPAddr{IP: ipnet.IP}, nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("no IPv4 on %s", iface)
|
||
}
|
||
func tcpDuration(iface, hp string) time.Duration {
|
||
la, err := localAddr(iface)
|
||
if err != nil {
|
||
return tcpTimeout
|
||
}
|
||
d := net.Dialer{Timeout: tcpTimeout, LocalAddr: la}
|
||
st := time.Now()
|
||
c, err := d.Dial("tcp", hp)
|
||
if err != nil {
|
||
return tcpTimeout
|
||
}
|
||
c.Close()
|
||
return time.Since(st)
|
||
}
|
||
|
||
/*────────────────── spinner ────────────────────*/
|
||
|
||
var spins = []rune{'|', '/', '-', '\\'}
|
||
|
||
// Update to add a channel for signaling UI updates
|
||
var uiUpdateChan = make(chan struct{}, 100) // Buffered channel to avoid blocking
|
||
|
||
// Advances the spinner frame and signals a UI update
|
||
// This is called only when packets are successfully received,
|
||
// so the spinner only moves when there's actual network activity
|
||
func (st *InterfaceStatus) spin() {
|
||
st.SpinFrame = (st.SpinFrame + 1) % len(spins)
|
||
// Signal UI update after spinner changes
|
||
select {
|
||
case uiUpdateChan <- struct{}{}:
|
||
default:
|
||
// Non-blocking send - if channel is full, just continue
|
||
}
|
||
}
|
||
|
||
/*────────────────── 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) }
|
||
|
||
/*────────────────── UI drawing ─────────────────*/
|
||
|
||
func ifaceHealthy(st *InterfaceStatus) bool {
|
||
st.mu.RLock()
|
||
defer st.mu.RUnlock()
|
||
if st.DroppedCount > 0 {
|
||
return false
|
||
}
|
||
for _, ok := range st.Reachable {
|
||
if !ok {
|
||
return false
|
||
}
|
||
}
|
||
for _, lp := range st.Loss {
|
||
if lp > 0 {
|
||
return false
|
||
}
|
||
}
|
||
for _, hp := range tcpTestHosts {
|
||
h := st.TCP[hp]
|
||
if len(h) == 0 || h[len(h)-1] >= float64(tcpTimeout.Milliseconds()) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
const (
|
||
hostW = 30 // Increased width for host column
|
||
numW = 7
|
||
stdW = 8
|
||
nW = 6
|
||
)
|
||
|
||
func drawIface(scr tcell.Screen, y, w int, st *InterfaceStatus) int {
|
||
put(scr, 0, y, hline(w), cDefault)
|
||
|
||
// header line
|
||
healthy := ifaceHealthy(st)
|
||
style := cBrightGreen
|
||
if !healthy {
|
||
style = cBrightRed
|
||
}
|
||
st.mu.RLock()
|
||
header := fmt.Sprintf("%s: %s — %s", st.Name, st.Label, st.IPInfo)
|
||
spin := spins[st.SpinFrame]
|
||
meterValue := st.MeterValue
|
||
st.mu.RUnlock()
|
||
|
||
// Create the meter - fixed width of 6 characters
|
||
meter := createMeter(meterValue, meterWidth)
|
||
|
||
put(scr, 0, y+1, "== ", cDefault)
|
||
put(scr, 3, y+1, string(spin)+" ", cDefault)
|
||
put(scr, 5, y+1, meter+" ", cDefault) // Add the meter after the spinner
|
||
put(scr, 5+meterWidth+1, y+1, header, style) // Move the header after the meter
|
||
put(scr, 0, y+2, hline(w), cDefault)
|
||
y += 4
|
||
|
||
/* reachability */
|
||
st.mu.RLock()
|
||
total := len(st.Reachable)
|
||
good := 0
|
||
for _, ok := range st.Reachable {
|
||
if ok {
|
||
good++
|
||
}
|
||
}
|
||
age := time.Since(st.LastPing).Round(time.Second)
|
||
reachStyle := cBrightGreen
|
||
if good != total {
|
||
reachStyle = cBrightRed
|
||
}
|
||
put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)",
|
||
good, total, st.LastPing.Format("15:04:05"), age), reachStyle)
|
||
y++
|
||
if good == total {
|
||
put(scr, 0, y, "Unreachable: none", cDefault)
|
||
} else {
|
||
var down []string
|
||
for h, ok := range st.Reachable {
|
||
if !ok {
|
||
down = append(down, h)
|
||
}
|
||
}
|
||
put(scr, 0, y, "Unreachable: "+strings.Join(down, ", "), cBrightRed)
|
||
}
|
||
y += 2
|
||
|
||
/* packet loss */
|
||
put(scr, 0, y, "Packet Loss:", cDefault)
|
||
y++
|
||
for _, h := range packetLossHosts {
|
||
p := st.Loss[h] * 100
|
||
put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", p), styleLoss(p))
|
||
y++
|
||
}
|
||
dAge := "N/A"
|
||
if !st.LastDrop.IsZero() {
|
||
dAge = time.Since(st.LastDrop).Round(time.Second).String()
|
||
}
|
||
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
|
||
|
||
/* TCP table */
|
||
put(scr, 0, y, "TCP Connect Stats:", cDefault)
|
||
y++
|
||
// header row
|
||
headerRow := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*s",
|
||
hostW, "Host", numW, "last", numW, "min", numW, "avg",
|
||
numW, "max", stdW, "stddev", nW, "n")
|
||
put(scr, 0, y, headerRow, cDefault)
|
||
y++
|
||
|
||
for _, hp := range tcpTestHosts {
|
||
hist := st.TCP[hp]
|
||
if len(hist) == 0 {
|
||
continue
|
||
}
|
||
last := hist[len(hist)-1]
|
||
mi, ma, av, sd := minMaxAvgStd(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))
|
||
put(scr, hostW+1+numW*4+4, y, fmt.Sprintf("%*s", stdW, fmt.Sprintf("%.0fms", sd)), cDefault)
|
||
y++
|
||
}
|
||
y++
|
||
|
||
/* totals */
|
||
put(scr, 0, y, fmt.Sprintf("Total ICMP Requests: %d", st.TotalICMPReq), cDefault)
|
||
y++
|
||
put(scr, 0, y, fmt.Sprintf("Total ICMP Replies: %d", st.TotalICMPRep), cDefault)
|
||
y += 2
|
||
st.mu.RUnlock()
|
||
return y
|
||
}
|
||
|
||
// Create a visual meter using green and red dots
|
||
func createMeter(value, width int) string {
|
||
if value > maxMeterValue {
|
||
value = maxMeterValue
|
||
}
|
||
if value < 0 {
|
||
value = 0
|
||
}
|
||
|
||
// Calculate how many dots of each color to show
|
||
redDots := value
|
||
if redDots > width {
|
||
redDots = width
|
||
}
|
||
greenDots := width - redDots
|
||
|
||
// Build the meter string
|
||
var b strings.Builder
|
||
|
||
// Add red dots for unreplied packets
|
||
for i := 0; i < redDots; i++ {
|
||
b.WriteRune(redDot)
|
||
}
|
||
|
||
// Add green dots for available capacity
|
||
for i := 0; i < greenDots; i++ {
|
||
b.WriteRune(greenDot)
|
||
}
|
||
|
||
return b.String()
|
||
}
|
||
|
||
/*────────────────── goroutine loops ─────────────*/
|
||
|
||
func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
||
logf("Starting reachability monitoring for %s with %d hosts", st.Name, len(hosts))
|
||
tk := time.NewTicker(time.Second)
|
||
defer tk.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
logf("Stopping reachability monitoring for %s", st.Name)
|
||
return
|
||
case <-tk.C:
|
||
// logf("Checking reachability for %s", st.Name) // Too verbose
|
||
var wg sync.WaitGroup
|
||
res := make(map[string]bool, len(hosts))
|
||
mu := sync.Mutex{}
|
||
for _, h := range hosts {
|
||
wg.Add(1)
|
||
go func(host string) {
|
||
defer wg.Done()
|
||
st.mu.Lock()
|
||
st.TotalICMPReq++
|
||
// Increase meter value when a packet is sent
|
||
st.MeterValue++
|
||
if st.MeterValue > maxMeterValue {
|
||
st.MeterValue = maxMeterValue
|
||
}
|
||
st.mu.Unlock()
|
||
|
||
ok := pingOnce(st.Name, host)
|
||
mu.Lock()
|
||
res[host] = ok
|
||
mu.Unlock()
|
||
|
||
st.mu.Lock()
|
||
if ok {
|
||
st.TotalICMPRep++
|
||
// Decrease meter value when a packet is successfully received
|
||
st.MeterValue--
|
||
if st.MeterValue < 0 {
|
||
st.MeterValue = 0
|
||
}
|
||
// Only update spinner when packets are successfully received
|
||
st.spin()
|
||
} else {
|
||
st.DroppedCount++
|
||
st.LastDrop = time.Now()
|
||
// For failed pings, we don't decrease the meter value
|
||
// Trigger UI update on ping failure
|
||
select {
|
||
case uiUpdateChan <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
st.mu.Unlock()
|
||
}(h)
|
||
}
|
||
wg.Wait()
|
||
|
||
// Check if reachability status changed
|
||
statusChanged := false
|
||
st.mu.Lock()
|
||
for host, newStatus := range res {
|
||
if oldStatus, ok := st.Reachable[host]; !ok || oldStatus != newStatus {
|
||
statusChanged = true
|
||
break
|
||
}
|
||
}
|
||
|
||
st.Reachable = res
|
||
st.LastPing = time.Now()
|
||
|
||
// Reset DroppedCount if all hosts are reachable
|
||
allReachable := true
|
||
for _, ok := range res {
|
||
if !ok {
|
||
allReachable = false
|
||
break
|
||
}
|
||
}
|
||
if allReachable {
|
||
st.DroppedCount = 0
|
||
// If all hosts are reachable, gradually decay the meter value
|
||
if st.MeterValue > 0 {
|
||
st.MeterValue--
|
||
}
|
||
}
|
||
st.mu.Unlock()
|
||
|
||
// Always trigger UI update when reachability status changes
|
||
if statusChanged {
|
||
select {
|
||
case uiUpdateChan <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
||
logf("Starting packet loss monitoring for %s with %d hosts", st.Name, len(hosts))
|
||
tk := time.NewTicker(packetLossPeriod)
|
||
defer tk.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
logf("Stopping packet loss monitoring for %s", st.Name)
|
||
return
|
||
case <-tk.C:
|
||
// logf("Checking packet loss for %s", st.Name) // Too verbose
|
||
var wg sync.WaitGroup
|
||
res := make(map[string]float64, len(hosts))
|
||
mu := sync.Mutex{}
|
||
for _, h := range hosts {
|
||
wg.Add(1)
|
||
go func(host string) {
|
||
defer wg.Done()
|
||
lp := lossPercent(st.Name, host)
|
||
mu.Lock()
|
||
res[host] = lp
|
||
mu.Unlock()
|
||
|
||
st.mu.Lock()
|
||
if lp == 0 {
|
||
// Only update spinner when there's 0% packet loss (successful receipt)
|
||
st.spin()
|
||
} else {
|
||
// Trigger UI update on packet loss
|
||
select {
|
||
case uiUpdateChan <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
st.mu.Unlock()
|
||
}(h)
|
||
}
|
||
wg.Wait()
|
||
|
||
// Check if loss status changed
|
||
statusChanged := false
|
||
st.mu.Lock()
|
||
for host, newLoss := range res {
|
||
if oldLoss, ok := st.Loss[host]; !ok || math.Abs(oldLoss-newLoss) > 0.01 {
|
||
statusChanged = true
|
||
break
|
||
}
|
||
}
|
||
|
||
for k, v := range res {
|
||
st.Loss[k] = v
|
||
}
|
||
st.mu.Unlock()
|
||
|
||
// Always trigger UI update when loss status changes
|
||
if statusChanged {
|
||
select {
|
||
case uiUpdateChan <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
||
logf("Starting TCP monitoring for %s with %d hosts", st.Name, len(hosts))
|
||
tk := time.NewTicker(time.Second)
|
||
defer tk.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
logf("Stopping TCP monitoring for %s", st.Name)
|
||
return
|
||
case <-tk.C:
|
||
// logf("Checking TCP for %s", st.Name) // Too verbose
|
||
statusChanged := false
|
||
|
||
for _, hp := range hosts {
|
||
ms := float64(tcpDuration(st.Name, hp).Milliseconds())
|
||
st.mu.Lock()
|
||
|
||
// Check if TCP latency significantly changed
|
||
hist := st.TCP[hp]
|
||
if len(hist) > 0 {
|
||
lastMs := hist[len(hist)-1]
|
||
if math.Abs(lastMs-ms) > 20 { // 20ms threshold for significant change
|
||
statusChanged = true
|
||
}
|
||
} else {
|
||
// First measurement
|
||
statusChanged = true
|
||
}
|
||
|
||
if ms < float64(tcpTimeout.Milliseconds()) {
|
||
// Only update spinner on successful TCP connections
|
||
st.spin()
|
||
} else {
|
||
// Trigger UI update on TCP timeout
|
||
select {
|
||
case uiUpdateChan <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
|
||
if len(hist) >= statsHistory {
|
||
hist = hist[1:]
|
||
}
|
||
st.TCP[hp] = append(hist, ms)
|
||
st.mu.Unlock()
|
||
}
|
||
|
||
// Always trigger UI update when TCP status changes significantly
|
||
if statusChanged {
|
||
select {
|
||
case uiUpdateChan <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/*────────────────── UI loop ─────────────────────*/
|
||
|
||
func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) {
|
||
logf("UI loop started")
|
||
defer func() {
|
||
logf("UI loop cleanup")
|
||
scr.Clear()
|
||
scr.ShowCursor(0, 0)
|
||
scr.Fini()
|
||
logf("Screen finalized")
|
||
}()
|
||
|
||
// Remove the ticker as we'll update on spinner ticks
|
||
// tk := time.NewTicker(screenRefresh)
|
||
// defer tk.Stop()
|
||
spin := 0
|
||
|
||
// Function to draw the screen
|
||
drawScreen := func() {
|
||
w, _ := scr.Size()
|
||
scr.Clear()
|
||
put(scr, 0, 0, hline(w), cDefault)
|
||
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, "Runtime: "+time.Since(start).Round(time.Second).String(), cDefault)
|
||
y := 5
|
||
y = drawIface(scr, y, w, a)
|
||
_ = drawIface(scr, y, w, b)
|
||
scr.Show()
|
||
}
|
||
|
||
// Initial draw
|
||
drawScreen()
|
||
|
||
// Even without a ticker, ensure we update at least every second
|
||
// This is a backup in case there are no spinner updates
|
||
backupTicker := time.NewTicker(time.Second)
|
||
defer backupTicker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
logf("Context cancelled, exiting UI loop")
|
||
return
|
||
case <-uiUpdateChan:
|
||
// Update on spinner ticks (no rate limiting)
|
||
drawScreen()
|
||
case <-backupTicker.C:
|
||
// Fallback to ensure we update at least once per second
|
||
drawScreen()
|
||
}
|
||
}
|
||
}
|
||
|
||
/*────────────────── main ─────────────────────────*/
|
||
|
||
var (
|
||
reachHosts []string
|
||
packetLossHosts = []string{"github.com", "google.com", "1.1.1.1", "8.8.8.8"}
|
||
tcpTestHosts = []string{
|
||
"datavi.be:443", "fast.com:443", "cloudflare.com:443",
|
||
"console.aws.amazon.com:443", "console.cloud.google.com:443",
|
||
}
|
||
)
|
||
|
||
// Simple logging function
|
||
func logf(format string, v ...interface{}) {
|
||
if logFile == nil || *logFile == "" {
|
||
return
|
||
}
|
||
f, err := os.OpenFile(*logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||
if err != nil {
|
||
return // silently fail if we can't log
|
||
}
|
||
defer f.Close()
|
||
fmt.Fprintf(f, time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...)
|
||
}
|
||
|
||
func main() {
|
||
flag.Parse()
|
||
logf("Starting rtnetmon")
|
||
reachHosts = strings.Split(*hostCSV, ",")
|
||
logf("Monitoring interfaces %s and %s", *ifaceA, *ifaceB)
|
||
|
||
// Initialize UI update channel
|
||
uiUpdateChan = make(chan struct{}, 100)
|
||
logf("UI update channel initialized with buffer size 100")
|
||
|
||
newStatus := func(name, label string) *InterfaceStatus {
|
||
logf("Initializing status for interface %s", name)
|
||
return &InterfaceStatus{
|
||
Name: name,
|
||
Label: label,
|
||
IPInfo: fetchIPInfo(name),
|
||
Reachable: map[string]bool{},
|
||
Loss: map[string]float64{},
|
||
TCP: map[string][]float64{},
|
||
MeterValue: 0, // Initialize meter value to 0
|
||
}
|
||
}
|
||
a, b := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB)
|
||
|
||
logf("Initializing screen")
|
||
scr, err := tcell.NewScreen()
|
||
if err != nil {
|
||
logf("Error creating screen: %v", err)
|
||
panic(err)
|
||
}
|
||
if err = scr.Init(); err != nil {
|
||
logf("Error initializing screen: %v", err)
|
||
panic(err)
|
||
}
|
||
logf("Screen initialized")
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
|
||
sig := make(chan os.Signal, 1)
|
||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||
go func() {
|
||
s := <-sig
|
||
logf("Signal received: %v", s)
|
||
cancel()
|
||
}()
|
||
|
||
// Event polling loop with logging
|
||
go func() {
|
||
logf("Starting keyboard event loop")
|
||
for {
|
||
if ev := scr.PollEvent(); ev != nil {
|
||
logf("Event received: %T", ev)
|
||
if ke, ok := ev.(*tcell.EventKey); ok {
|
||
logf("Key event: %v, rune: %c", ke.Key(), ke.Rune())
|
||
if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' {
|
||
logf("Quit key detected")
|
||
cancel()
|
||
return
|
||
}
|
||
}
|
||
|
||
// Trigger UI update on any event
|
||
select {
|
||
case uiUpdateChan <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
|
||
// Start monitoring goroutines
|
||
logf("Starting monitoring goroutines")
|
||
go reachLoop(ctx, a, reachHosts)
|
||
go reachLoop(ctx, b, reachHosts)
|
||
go lossLoop(ctx, a, packetLossHosts)
|
||
go lossLoop(ctx, b, packetLossHosts)
|
||
go tcpLoop(ctx, a, tcpTestHosts)
|
||
go tcpLoop(ctx, b, tcpTestHosts)
|
||
|
||
logf("Starting UI loop")
|
||
uiLoop(ctx, scr, a, b, time.Now())
|
||
logf("UI loop exited, program ending")
|
||
}
|
||
|
||
/*────────────────── 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). */
|