higher frame rate, PL meter

This commit is contained in:
2025-05-21 21:24:20 -07:00
parent 29b96b3405
commit 2ad2aef700
+277 -32
View File
@@ -1,3 +1,6 @@
//go:build linux
// +build linux
// netmon dual-interface network dashboard (curses) // netmon dual-interface network dashboard (curses)
// WTFPL 2025-05-16 sneak@sneak.berlin // WTFPL 2025-05-16 sneak@sneak.berlin
package main package main
@@ -34,6 +37,8 @@ var (
"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",
"comma-separated reachability hosts") "comma-separated reachability hosts")
logFile = flag.String("logfile", "/tmp/rtnetmon.log", "path to log file")
) )
/*────────────────── constants ──────────────────*/ /*────────────────── constants ──────────────────*/
@@ -44,7 +49,14 @@ const (
packetLossPings = 20 packetLossPings = 20
packetLossPeriod = 5 * time.Second packetLossPeriod = 5 * time.Second
statsHistory = 300 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 ─────────────*/ /*────────────────── runtime struct ─────────────*/
@@ -58,6 +70,7 @@ type InterfaceStatus struct {
DroppedCount int DroppedCount int
LastDrop, LastPing time.Time LastDrop, LastPing time.Time
SpinFrame int SpinFrame int
MeterValue int // Current value for the packet loss meter
mu sync.RWMutex mu sync.RWMutex
} }
@@ -200,7 +213,21 @@ func tcpDuration(iface, hp string) time.Duration {
var spins = []rune{'|', '/', '-', '\\'} 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 ─────────────*/ /*────────────────── screen helpers ─────────────*/
@@ -257,9 +284,16 @@ func drawIface(scr tcell.Screen, y, w int, st *InterfaceStatus) int {
st.mu.RLock() st.mu.RLock()
header := fmt.Sprintf("%s: %s — %s", st.Name, st.Label, st.IPInfo) header := fmt.Sprintf("%s: %s — %s", st.Name, st.Label, st.IPInfo)
spin := spins[st.SpinFrame] spin := spins[st.SpinFrame]
meterValue := st.MeterValue
st.mu.RUnlock() st.mu.RUnlock()
// Create the meter - fixed width of 6 characters
meter := createMeter(meterValue, meterWidth)
put(scr, 0, y+1, "== ", cDefault) 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) put(scr, 0, y+2, hline(w), cDefault)
y += 4 y += 4
@@ -356,16 +390,51 @@ func drawIface(scr tcell.Screen, y, w int, st *InterfaceStatus) int {
return y 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 ─────────────*/ /*────────────────── goroutine loops ─────────────*/
func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { 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) tk := time.NewTicker(time.Second)
defer tk.Stop() defer tk.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
logf("Stopping reachability monitoring for %s", st.Name)
return return
case <-tk.C: case <-tk.C:
// logf("Checking reachability for %s", st.Name) // Too verbose
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{}
@@ -375,6 +444,11 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
defer wg.Done() defer wg.Done()
st.mu.Lock() st.mu.Lock()
st.TotalICMPReq++ st.TotalICMPReq++
// Increase meter value when a packet is sent
st.MeterValue++
if st.MeterValue > maxMeterValue {
st.MeterValue = maxMeterValue
}
st.mu.Unlock() st.mu.Unlock()
ok := pingOnce(st.Name, host) ok := pingOnce(st.Name, host)
@@ -385,18 +459,41 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
st.mu.Lock() st.mu.Lock()
if ok { if ok {
st.TotalICMPRep++ 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() st.spin()
} else { } else {
st.DroppedCount++ st.DroppedCount++
st.LastDrop = time.Now() 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() st.mu.Unlock()
}(h) }(h)
} }
wg.Wait() wg.Wait()
// Check if reachability status changed
statusChanged := false
st.mu.Lock() st.mu.Lock()
for host, newStatus := range res {
if oldStatus, ok := st.Reachable[host]; !ok || oldStatus != newStatus {
statusChanged = true
break
}
}
st.Reachable = res st.Reachable = res
st.LastPing = time.Now() st.LastPing = time.Now()
// Reset DroppedCount if all hosts are reachable // Reset DroppedCount if all hosts are reachable
allReachable := true allReachable := true
for _, ok := range res { for _, ok := range res {
@@ -407,19 +504,34 @@ func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
} }
if allReachable { if allReachable {
st.DroppedCount = 0 st.DroppedCount = 0
// If all hosts are reachable, gradually decay the meter value
if st.MeterValue > 0 {
st.MeterValue--
}
} }
st.mu.Unlock() 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) { 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) tk := time.NewTicker(packetLossPeriod)
defer tk.Stop() defer tk.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
logf("Stopping packet loss monitoring for %s", st.Name)
return return
case <-tk.C: case <-tk.C:
// logf("Checking packet loss for %s", st.Name) // Too verbose
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{}
@@ -430,42 +542,103 @@ 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 == 0 {
st.spin()
}
mu.Unlock() 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) }(h)
} }
wg.Wait() wg.Wait()
// Check if loss status changed
statusChanged := false
st.mu.Lock() 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 { for k, v := range res {
st.Loss[k] = v st.Loss[k] = v
} }
st.mu.Unlock() 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) { 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) tk := time.NewTicker(time.Second)
defer tk.Stop() defer tk.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
logf("Stopping TCP monitoring for %s", st.Name)
return return
case <-tk.C: case <-tk.C:
// logf("Checking TCP for %s", st.Name) // Too verbose
statusChanged := false
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()) {
st.spin() // Check if TCP latency significantly changed
}
hist := st.TCP[hp] 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 { if len(hist) >= statsHistory {
hist = hist[1:] hist = hist[1:]
} }
st.TCP[hp] = append(hist, ms) st.TCP[hp] = append(hist, ms)
st.mu.Unlock() 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 ─────────────────────*/ /*────────────────── 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() logf("UI loop started")
tk := time.NewTicker(screenRefresh) defer func() {
defer tk.Stop() 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 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 { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
logf("Context cancelled, exiting UI loop")
return return
case <-tk.C: case <-uiUpdateChan:
w, _ := scr.Size() // Update on spinner ticks (no rate limiting)
scr.Clear() drawScreen()
put(scr, 0, 0, hline(w), cDefault) case <-backupTicker.C:
put(scr, 0, 1, fmt.Sprintf("== %c %s", spins[spin%len(spins)], time.Now().Format(time.RFC1123Z)), cDefault) // Fallback to ensure we update at least once per second
put(scr, 0, 2, hline(w), cDefault) drawScreen()
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()
} }
} }
} }
@@ -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() { func main() {
flag.Parse() flag.Parse()
logf("Starting rtnetmon")
reachHosts = strings.Split(*hostCSV, ",") 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 { newStatus := func(name, label string) *InterfaceStatus {
logf("Initializing status for interface %s", name)
return &InterfaceStatus{ return &InterfaceStatus{
Name: name, Name: name,
Label: label, Label: label,
IPInfo: fetchIPInfo(name), 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{},
MeterValue: 0, // Initialize meter value to 0
} }
} }
a, b := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB) a, b := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB)
logf("Initializing screen")
scr, err := tcell.NewScreen() scr, err := tcell.NewScreen()
if err != nil { if err != nil {
logf("Error creating screen: %v", err)
panic(err) panic(err)
} }
if err = scr.Init(); err != nil { if err = scr.Init(); err != nil {
logf("Error initializing screen: %v", err)
panic(err) panic(err)
} }
logf("Screen initialized")
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
sig := make(chan os.Signal, 1) sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM) signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() { <-sig; cancel() }()
go func() { go func() {
s := <-sig
logf("Signal received: %v", s)
cancel()
}()
// Event polling loop with logging
go func() {
logf("Starting keyboard event loop")
for { for {
if ev := scr.PollEvent(); ev != nil { if ev := scr.PollEvent(); ev != nil {
logf("Event received: %T", ev)
if ke, ok := ev.(*tcell.EventKey); ok { 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' { if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' {
logf("Quit key detected")
cancel() cancel()
return 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, a, reachHosts)
go reachLoop(ctx, b, reachHosts) go reachLoop(ctx, b, reachHosts)
go lossLoop(ctx, a, packetLossHosts) go lossLoop(ctx, a, packetLossHosts)
@@ -559,7 +802,9 @@ func main() {
go tcpLoop(ctx, a, tcpTestHosts) go tcpLoop(ctx, a, tcpTestHosts)
go tcpLoop(ctx, b, tcpTestHosts) go tcpLoop(ctx, b, tcpTestHosts)
logf("Starting UI loop")
uiLoop(ctx, scr, a, b, time.Now()) uiLoop(ctx, scr, a, b, time.Now())
logf("UI loop exited, program ending")
} }
/*────────────────── Extra packet-loss hosts ───── /*────────────────── Extra packet-loss hosts ─────