Files
rtnetmon/main.go
T
2025-05-21 21:34:06 -07:00

920 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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"
"math/rand"
"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",
"8.8.8.8,8.8.4.4,google.com,github.com,"+
"console.aws.amazon.com,console.cloud.google.com,"+
"fast.com,datavi.be,captive.apple.com",
"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
// ASCII characters for the meter
meterStart = '['
meterEnd = ']'
meterFill = '='
meterEmpty = ' '
meterWidth = 7 // Fixed width of the meter (including brackets)
meterFillWidth = 5 // Width of the fillable area (meterWidth - 2 for brackets)
maxMeterValue = 10 // Maximum value for the meter
hostW = 30 // Increased width for host column
numW = 7
stdW = 8
nW = 6
lostW = 7 // Width for the lost column
)
/*────────────────── 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
LostPackets map[string]int // Track lost packets per host
mu sync.RWMutex
}
/*────────────────── colour styles ──────────────*/
var (
cBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true)
cGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen)
cDimGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Dim(true)
cYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow)
cOrange = tcell.StyleDefault.Foreground(tcell.ColorOrange)
cRed = tcell.StyleDefault.Foreground(tcell.ColorRed)
cBrightRed = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true)
cDefault = tcell.StyleDefault
// Rainbow colors for the spinner
cRainbow = []tcell.Style{
tcell.StyleDefault.Foreground(tcell.ColorRed),
tcell.StyleDefault.Foreground(tcell.ColorOrange),
tcell.StyleDefault.Foreground(tcell.ColorYellow),
tcell.StyleDefault.Foreground(tcell.ColorGreen),
tcell.StyleDefault.Foreground(tcell.ColorBlue),
tcell.StyleDefault.Foreground(tcell.ColorPurple),
}
)
// Get the appropriate meter style based on the value
func getMeterStyle(value int) tcell.Style {
switch {
case value <= 0:
return cBrightGreen
case value == 1:
return cGreen
case value == 2:
return cDimGreen
case value == 3:
return cYellow
case value == 4:
return cOrange
default:
return cRed
}
}
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
}
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)
spinChar := spins[st.SpinFrame]
spinnerFrame := st.SpinFrame
meterValue := st.MeterValue
st.mu.RUnlock()
// Create the meter with appropriate style
meter, meterStyle := createMeter(meterValue, meterWidth)
// Get rainbow color for spinner (cycle through colors)
spinnerStyle := cRainbow[spinnerFrame%len(cRainbow)]
put(scr, 0, y+1, "== ", cDefault)
put(scr, 3, y+1, string(spinChar)+" ", spinnerStyle) // Colorful spinner
put(scr, 5, y+1, meter+" ", meterStyle) // Colored meter
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 %*s",
hostW, "Host", numW, "last", numW, "min", numW, "avg",
numW, "max", stdW, "stddev", nW, "n", lostW, "lost")
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)
// Get host name without port for lost packets lookup
hostName := strings.Split(hp, ":")[0]
lost := st.LostPackets[hostName]
// Add lost column
row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d %*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),
lostW, lost,
)
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)
// Colorize the lost packets column
lostStyle := cDefault
if lost > 0 {
lostStyle = cRed
}
put(scr, hostW+1+numW*4+4+stdW+1+nW+1, y, fmt.Sprintf("%*d", lostW, lost), lostStyle)
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++
// Add a line showing lost packets
lost := st.TotalICMPReq - st.TotalICMPRep
lostStyle := cDefault
if lost > 0 {
lostStyle = cRed
}
put(scr, 0, y, fmt.Sprintf("Total ICMP Lost: %d", lost), lostStyle)
y += 2
st.mu.RUnlock()
return y
}
// Create a visual meter using ASCII characters
// Returns the meter string and the appropriate style
func createMeter(value, width int) (string, tcell.Style) {
if value > maxMeterValue {
value = maxMeterValue
}
if value < 0 {
value = 0
}
// Calculate the number of fill characters to show
// Map value (0-maxMeterValue) to fill width (0-meterFillWidth)
fillCount := value * meterFillWidth / maxMeterValue
if fillCount > meterFillWidth {
fillCount = meterFillWidth
}
// Build the meter string
var b strings.Builder
// Add opening bracket
b.WriteRune(meterStart)
// Add fill characters
for i := 0; i < fillCount; i++ {
b.WriteRune(meterFill)
}
// Add empty spaces
for i := 0; i < meterFillWidth-fillCount; i++ {
b.WriteRune(meterEmpty)
}
// Add closing bracket
b.WriteRune(meterEnd)
// Get the appropriate style for this meter value
style := getMeterStyle(value)
return b.String(), style
}
/*────────────────── goroutine loops ─────────────*/
func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
logf("Starting reachability monitoring for %s with %d hosts", st.Name, len(hosts))
// Add random offset to avoid clustering at 1-second intervals
randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond
logf("Reachability monitoring for %s will start after %v offset", st.Name, randomOffset)
time.Sleep(randomOffset)
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()
// Track lost packets per host
st.LostPackets[host]++
// 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))
// Add random offset to avoid clustering at periodic intervals
randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond
logf("Packet loss monitoring for %s will start after %v offset", st.Name, randomOffset)
time.Sleep(randomOffset)
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))
// Add random offset to avoid clustering at 1-second intervals
randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond
logf("TCP monitoring for %s will start after %v offset", st.Name, randomOffset)
time.Sleep(randomOffset)
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", "8.8.8.8", "captive.apple.com"}
tcpTestHosts = []string{
"datavi.be:443", "fast.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")
// Seed the random number generator
rand.Seed(time.Now().UnixNano())
logf("Random number generator seeded")
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
LostPackets: map[string]int{}, // Initialize lost packets map
}
}
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). */