higher frame rate, PL meter

This commit is contained in:
2025-05-21 21:24:20 -07:00
parent 29b96b3405
commit 2ad2aef700
+264 -19
View File
@@ -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,15 +646,22 @@ 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
for {
select {
case <-ctx.Done():
return
case <-tk.C:
// Function to draw the screen
drawScreen := func() {
w, _ := scr.Size()
scr.Clear()
put(scr, 0, 0, hline(w), cDefault)
@@ -494,6 +674,27 @@ func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start
_ = 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()
}
}
}
@@ -508,11 +709,31 @@ 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,
@@ -520,38 +741,60 @@ func main() {
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 ─────