higher frame rate, PL meter
This commit is contained in:
@@ -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 ─────
|
||||
|
||||
Reference in New Issue
Block a user