diff --git a/main.go b/main.go index e5a68f3..351dac0 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,6 @@ +//go:build linux +// +build linux + // netmon – dual-interface network dashboard (curses) // WTFPL – 2025-05-16 sneak@sneak.berlin package main @@ -34,6 +37,8 @@ var ( "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 ──────────────────*/ @@ -44,7 +49,14 @@ const ( packetLossPings = 20 packetLossPeriod = 5 * time.Second statsHistory = 300 - screenRefresh = 500 * time.Millisecond + 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 ─────────────*/ @@ -58,6 +70,7 @@ type InterfaceStatus struct { DroppedCount int LastDrop, LastPing time.Time SpinFrame int + MeterValue int // Current value for the packet loss meter mu sync.RWMutex } @@ -200,7 +213,21 @@ func tcpDuration(iface, hp string) time.Duration { var spins = []rune{'|', '/', '-', '\\'} -func (st *InterfaceStatus) spin() { st.SpinFrame = (st.SpinFrame + 1) % len(spins) } +// 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 ─────────────*/ @@ -257,9 +284,16 @@ func drawIface(scr tcell.Screen, y, w int, st *InterfaceStatus) int { 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)+" "+header, style) + 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 @@ -356,16 +390,51 @@ func drawIface(scr tcell.Screen, y, w int, st *InterfaceStatus) int { 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{} @@ -375,6 +444,11 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []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) @@ -385,18 +459,41 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { 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 { @@ -407,19 +504,34 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { } 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{} @@ -430,42 +542,103 @@ func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { lp := lossPercent(st.Name, host) mu.Lock() res[host] = lp - if lp == 0 { - st.spin() - } 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() - if ms < float64(tcpTimeout.Milliseconds()) { - st.spin() - } + + // 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: + } + } } } } @@ -473,26 +646,54 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { /*────────────────── UI loop ─────────────────────*/ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) { - defer scr.Fini() - tk := time.NewTicker(screenRefresh) - defer tk.Stop() + 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 <-tk.C: - 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() + case <-uiUpdateChan: + // Update on spinner ticks (no rate limiting) + drawScreen() + case <-backupTicker.C: + // Fallback to ensure we update at least once per second + drawScreen() } } } @@ -508,50 +709,92 @@ var ( } ) +// 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{}, + 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() { <-sig; cancel() }() - 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) @@ -559,7 +802,9 @@ func main() { 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 ─────