This commit is contained in:
2026-01-06 10:20:34 -08:00
parent 0e1fc6a88d
commit a8ab26752a
15 changed files with 1806 additions and 929 deletions
+118
View File
@@ -0,0 +1,118 @@
//go:build linux
// +build linux
package cli
import (
"context"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
)
// Config holds the application configuration
type Config struct {
IfaceA string
LabelA string
IfaceB string
LabelB string
Hosts []string
LogFile string
}
var (
cfg Config
rootCmd = &cobra.Command{
Use: "rtnetmon",
Short: "Real-time network monitoring dashboard",
Long: `rtnetmon is a dual-interface network monitoring dashboard that provides
real-time visibility into network health, packet loss, and latency.`,
RunE: runMonitor,
}
)
// Default hosts for monitoring
var (
defaultReachabilityHosts = []string{
"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",
}
defaultPacketLossHosts = []string{
"github.com", "google.com", "8.8.8.8", "captive.apple.com", "62.115.190.68",
}
defaultTCPHosts = []string{
"datavi.be:443", "fast.com:443",
"console.aws.amazon.com:443", "console.cloud.google.com:443",
"captive.apple.com:80", "google.com:443",
}
)
func init() {
// Define flags
rootCmd.Flags().StringVar(&cfg.IfaceA, "ifaceA", "gu0", "primary network interface")
rootCmd.Flags().StringVar(&cfg.LabelA, "labelA", "gu LAN - VPN outbound", "label for ifaceA")
rootCmd.Flags().StringVar(&cfg.IfaceB, "ifaceB", "backhaul0", "secondary network interface")
rootCmd.Flags().StringVar(&cfg.LabelB, "labelB", "Cox cable direct", "label for ifaceB")
rootCmd.Flags().StringSliceVar(&cfg.Hosts, "hosts", defaultReachabilityHosts, "comma-separated reachability hosts")
rootCmd.Flags().StringVar(&cfg.LogFile, "logfile", "/tmp/rtnetmon.log", "path to log file")
// Bind flags to viper
_ = viper.BindPFlag("ifaceA", rootCmd.Flags().Lookup("ifaceA"))
_ = viper.BindPFlag("labelA", rootCmd.Flags().Lookup("labelA"))
_ = viper.BindPFlag("ifaceB", rootCmd.Flags().Lookup("ifaceB"))
_ = viper.BindPFlag("labelB", rootCmd.Flags().Lookup("labelB"))
_ = viper.BindPFlag("hosts", rootCmd.Flags().Lookup("hosts"))
_ = viper.BindPFlag("logfile", rootCmd.Flags().Lookup("logfile"))
}
func runMonitor(cmd *cobra.Command, args []string) error {
monitor.Logf(cfg.LogFile, "Starting rtnetmon")
monitor.Logf(cfg.LogFile, "Monitoring interfaces %s and %s", cfg.IfaceA, cfg.IfaceB)
// Create the monitor
mon := monitor.NewMonitor(cfg.IfaceA, cfg.LabelA, cfg.IfaceB, cfg.LabelB, cfg.LogFile)
// Add reachability hosts
for _, host := range cfg.Hosts {
mon.AddReachabilityHost(host)
}
// Add packet loss hosts
for _, host := range defaultPacketLossHosts {
mon.AddPacketLossHost(host)
}
// Add TCP hosts
for _, host := range defaultTCPHosts {
mon.AddTCPHost(host)
}
// Create context with signal handling
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle signals
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() {
s := <-sig
monitor.Logf(cfg.LogFile, "Signal received: %v", s)
cancel()
}()
// Run the monitor
return mon.Run(ctx)
}
// Execute runs the root command
func Execute() error {
return rootCmd.Execute()
}
+36
View File
@@ -0,0 +1,36 @@
//go:build linux
// +build linux
package cli
import (
"testing"
)
// TestExecute tests that the CLI can be initialized
func TestExecute(t *testing.T) {
// This is a simple compilation test to ensure the CLI package compiles
// We can't easily test the full Execute() function as it starts the UI
// Test that rootCmd is properly initialized
if rootCmd == nil {
t.Fatal("rootCmd is nil")
}
if rootCmd.Use != "rtnetmon" {
t.Errorf("Expected rootCmd.Use to be 'rtnetmon', got '%s'", rootCmd.Use)
}
// Test that default configuration is set
if len(defaultReachabilityHosts) == 0 {
t.Error("defaultReachabilityHosts is empty")
}
if len(defaultPacketLossHosts) == 0 {
t.Error("defaultPacketLossHosts is empty")
}
if len(defaultTCPHosts) == 0 {
t.Error("defaultTCPHosts is empty")
}
}
+278
View File
@@ -0,0 +1,278 @@
//go:build linux
// +build linux
package monitor
import (
"context"
"math"
"math/rand"
"strings"
"sync"
"time"
)
// UIUpdateChan is used to signal UI updates
var UIUpdateChan = make(chan struct{}, 100)
// reachLoop monitors reachability for hosts
func (m *Monitor) reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
m.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
m.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():
m.logf("Stopping reachability monitoring for %s", st.Name)
return
case <-tk.C:
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 > m.MaxMeterValue {
st.MeterValue = m.MaxMeterValue
}
st.mu.Unlock()
ok := m.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]++
// 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()
// Check if all hosts are reachable
allReachable := true
for _, ok := range res {
if !ok {
allReachable = false
break
}
}
// If all hosts are reachable, gradually decay the meter value
if allReachable && st.MeterValue > 0 {
st.MeterValue--
}
st.mu.Unlock()
// Always trigger UI update when reachability status changes
if statusChanged {
select {
case UIUpdateChan <- struct{}{}:
default:
}
}
}
}
}
// lossLoop monitors packet loss for hosts
func (m *Monitor) lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
m.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
m.logf("Packet loss monitoring for %s will start after %v offset", st.Name, randomOffset)
time.Sleep(randomOffset)
tk := time.NewTicker(m.PacketLossPeriod)
defer tk.Stop()
for {
select {
case <-ctx.Done():
m.logf("Stopping packet loss monitoring for %s", st.Name)
return
case <-tk.C:
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 := m.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
st.Spin()
} else {
// Calculate approximate number of lost packets based on loss percentage
lostPackets := int(math.Ceil(float64(m.PacketLossPings) * lp))
// Update dropped count with the number of lost packets
st.DroppedCount += lostPackets
// Update last drop time if packets were lost
if lostPackets > 0 {
st.LastDrop = time.Now()
}
// Track lost packets per host
st.LostPackets[host] += lostPackets
// 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:
}
}
}
}
}
// tcpLoop monitors TCP connectivity for hosts
func (m *Monitor) tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
m.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
m.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():
m.logf("Stopping TCP monitoring for %s", st.Name)
return
case <-tk.C:
statusChanged := false
for _, hp := range hosts {
ms := float64(m.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(m.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) >= m.StatsHistory {
hist = hist[1:]
}
st.TCP[hp] = append(hist, ms)
// Update lost packets for the host (without port)
hostName := strings.Split(hp, ":")[0]
if ms >= float64(m.TCPTimeout.Milliseconds()) {
st.LostPackets[hostName]++
}
st.mu.Unlock()
}
// Always trigger UI update when TCP status changes significantly
if statusChanged {
select {
case UIUpdateChan <- struct{}{}:
default:
}
}
}
}
}
+394
View File
@@ -0,0 +1,394 @@
//go:build linux
// +build linux
package monitor
import (
"context"
"encoding/json"
"fmt"
"math"
"net"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
tcell "github.com/gdamore/tcell/v2"
)
// Non-configurable constants (meter characters)
const (
// ASCII characters for the meter
MeterStart = '['
MeterEnd = ']'
MeterFill = '='
MeterEmpty = ' '
)
// Monitor represents the network monitoring system
type Monitor struct {
// Configuration
ICMPTimeout time.Duration
TCPTimeout time.Duration
PacketLossPings int
PacketLossPeriod time.Duration
StatsHistory int
ScreenRefresh time.Duration
MeterWidth int
MeterFillWidth int
MaxMeterValue int
// Column widths
HostWidth int
NumWidth int
StdWidth int
NWidth int
LostWidth int
// Host lists
reachabilityHosts []string
packetLossHosts []string
tcpHosts []string
// Interfaces
interfaceA *InterfaceStatus
interfaceB *InterfaceStatus
// Logging
logFile string
// Runtime state
screen tcell.Screen
startTime time.Time
// Synchronization
mu sync.RWMutex
}
// NewMonitor creates a new Monitor instance with default settings
func NewMonitor(ifaceA, labelA, ifaceB, labelB, logFile string) *Monitor {
return &Monitor{
// Default timing configuration
ICMPTimeout: 500 * time.Millisecond,
TCPTimeout: 500 * time.Millisecond,
PacketLossPings: 20,
PacketLossPeriod: 5 * time.Second,
StatsHistory: 300,
ScreenRefresh: 500 * time.Millisecond,
// Default display configuration
MeterWidth: 7,
MeterFillWidth: 5,
MaxMeterValue: 10,
HostWidth: 30,
NumWidth: 7,
StdWidth: 8,
NWidth: 6,
LostWidth: 7,
// Initialize host lists
reachabilityHosts: []string{},
packetLossHosts: []string{},
tcpHosts: []string{},
// Initialize interfaces
interfaceA: NewInterfaceStatus(ifaceA, labelA),
interfaceB: NewInterfaceStatus(ifaceB, labelB),
// Logging
logFile: logFile,
startTime: time.Now(),
}
}
// AddReachabilityHost adds a host for reachability monitoring
func (m *Monitor) AddReachabilityHost(host string) {
m.mu.Lock()
defer m.mu.Unlock()
m.reachabilityHosts = append(m.reachabilityHosts, host)
}
// AddPacketLossHost adds a host for packet loss monitoring
func (m *Monitor) AddPacketLossHost(host string) {
m.mu.Lock()
defer m.mu.Unlock()
m.packetLossHosts = append(m.packetLossHosts, host)
}
// AddTCPHost adds a host:port for TCP connectivity monitoring
func (m *Monitor) AddTCPHost(hostPort string) {
m.mu.Lock()
defer m.mu.Unlock()
m.tcpHosts = append(m.tcpHosts, hostPort)
}
// Run starts the monitoring system
func (m *Monitor) Run(ctx context.Context) error {
m.logf("Starting monitor run")
// Initialize screen
m.logf("Initializing screen")
scr, err := tcell.NewScreen()
if err != nil {
m.logf("Error creating screen: %v", err)
return fmt.Errorf("error creating screen: %w", err)
}
if err = scr.Init(); err != nil {
m.logf("Error initializing screen: %v", err)
return fmt.Errorf("error initializing screen: %w", err)
}
m.screen = scr
m.logf("Screen initialized")
// Create cancellable context
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Handle keyboard events
go m.keyboardEventLoop(ctx, cancel)
// Start monitoring goroutines
m.logf("Starting monitoring goroutines")
m.mu.RLock()
reachHosts := append([]string{}, m.reachabilityHosts...)
lossHosts := append([]string{}, m.packetLossHosts...)
tcpHosts := append([]string{}, m.tcpHosts...)
m.mu.RUnlock()
go m.reachLoop(ctx, m.interfaceA, reachHosts)
go m.reachLoop(ctx, m.interfaceB, reachHosts)
go m.lossLoop(ctx, m.interfaceA, lossHosts)
go m.lossLoop(ctx, m.interfaceB, lossHosts)
go m.tcpLoop(ctx, m.interfaceA, tcpHosts)
go m.tcpLoop(ctx, m.interfaceB, tcpHosts)
// Run UI loop
m.logf("Starting UI loop")
m.uiLoop(ctx)
m.logf("UI loop exited, monitor ending")
return nil
}
// keyboardEventLoop handles keyboard input
func (m *Monitor) keyboardEventLoop(ctx context.Context, cancel context.CancelFunc) {
m.logf("Starting keyboard event loop")
for {
if ev := m.screen.PollEvent(); ev != nil {
m.logf("Event received: %T", ev)
if ke, ok := ev.(*tcell.EventKey); ok {
m.logf("Key event: %v, rune: %c", ke.Key(), ke.Rune())
if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' {
m.logf("Quit key detected")
cancel()
return
}
}
// Trigger UI update on any event
select {
case UIUpdateChan <- struct{}{}:
default:
}
}
}
}
// logf logs a formatted message
func (m *Monitor) logf(format string, v ...interface{}) {
Logf(m.logFile, format, v...)
}
// InterfaceStatus holds the runtime status for a network interface
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
LostPackets map[string]int
mu sync.RWMutex
}
// NewInterfaceStatus creates a new interface status
func NewInterfaceStatus(name, label string) *InterfaceStatus {
return &InterfaceStatus{
Name: name,
Label: label,
IPInfo: fetchIPInfo(name),
Reachable: map[string]bool{},
Loss: map[string]float64{},
TCP: map[string][]float64{},
MeterValue: 0,
LostPackets: map[string]int{},
}
}
// ipInfoResp holds the IP info response
type ipInfoResp struct {
IP string `json:"ip"`
Hostname string `json:"hostname"`
Org string `json:"org"`
}
// fetchIPInfo fetches IP information for an interface
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)
}
// pingOnce performs a single ping
func (m *Monitor) pingOnce(iface, host string) bool {
ctx, cancel := context.WithTimeout(context.Background(), m.ICMPTimeout)
defer cancel()
return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil
}
// lossPercent calculates packet loss percentage
func (m *Monitor) 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(m.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
}
// localAddr gets the local address for an interface
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)
}
// tcpDuration measures TCP connection duration
func (m *Monitor) tcpDuration(iface, hp string) time.Duration {
la, err := localAddr(iface)
if err != nil {
return m.TCPTimeout
}
d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la}
st := time.Now()
c, err := d.Dial("tcp", hp)
if err != nil {
return m.TCPTimeout
}
c.Close()
return time.Since(st)
}
// MinMaxAvgStd calculates min, max, average and standard deviation
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
}
// Spin advances the spinner frame
func (st *InterfaceStatus) Spin() {
st.SpinFrame = (st.SpinFrame + 1) % len(Spins)
}
// IsHealthy checks if the interface is healthy
func (st *InterfaceStatus) IsHealthy(tcpTimeout time.Duration) bool {
st.mu.RLock()
defer st.mu.RUnlock()
// Check if there are any currently unreachable hosts
for _, ok := range st.Reachable {
if !ok {
return false
}
}
// Check if there's any current packet loss
for _, lp := range st.Loss {
if lp > 0 {
return false
}
}
// Check if there are any TCP timeouts
for _, hist := range st.TCP {
if len(hist) == 0 || hist[len(hist)-1] >= float64(tcpTimeout.Milliseconds()) {
return false
}
}
return true
}
// Spinners and braille characters
var (
Spins = []rune{'|', '/', '-', '\\'}
BrailleSpins = []rune{
'⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋',
}
)
// Logf is a simple logging function
func Logf(logFile string, format string, v ...interface{}) {
if logFile == "" {
return
}
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...)
}
+126
View File
@@ -0,0 +1,126 @@
//go:build linux
// +build linux
package monitor
import (
"testing"
)
// TestNewMonitor tests the creation of a new Monitor
func TestNewMonitor(t *testing.T) {
// Test that we can create a monitor without errors
mon := NewMonitor("test0", "Test Interface A", "test1", "Test Interface B", "/tmp/test.log")
if mon == nil {
t.Fatal("NewMonitor returned nil")
}
// Check interfaces
if mon.interfaceA == nil {
t.Fatal("interfaceA is nil")
}
if mon.interfaceB == nil {
t.Fatal("interfaceB is nil")
}
if mon.interfaceA.Name != "test0" {
t.Errorf("Expected interfaceA.Name to be 'test0', got '%s'", mon.interfaceA.Name)
}
if mon.interfaceB.Name != "test1" {
t.Errorf("Expected interfaceB.Name to be 'test1', got '%s'", mon.interfaceB.Name)
}
// Check default configuration
if mon.ICMPTimeout.Milliseconds() != 500 {
t.Errorf("Expected ICMPTimeout to be 500ms, got %dms", mon.ICMPTimeout.Milliseconds())
}
if mon.PacketLossPings != 20 {
t.Errorf("Expected PacketLossPings to be 20, got %d", mon.PacketLossPings)
}
// Check that host lists are initialized
if mon.reachabilityHosts == nil {
t.Error("reachabilityHosts is nil")
}
if mon.packetLossHosts == nil {
t.Error("packetLossHosts is nil")
}
if mon.tcpHosts == nil {
t.Error("tcpHosts is nil")
}
}
// TestAddHosts tests adding hosts to the monitor
func TestAddHosts(t *testing.T) {
mon := NewMonitor("test0", "Test A", "test1", "Test B", "")
// Test adding reachability hosts
mon.AddReachabilityHost("8.8.8.8")
mon.AddReachabilityHost("google.com")
if len(mon.reachabilityHosts) != 2 {
t.Errorf("Expected 2 reachability hosts, got %d", len(mon.reachabilityHosts))
}
// Test adding packet loss hosts
mon.AddPacketLossHost("github.com")
if len(mon.packetLossHosts) != 1 {
t.Errorf("Expected 1 packet loss host, got %d", len(mon.packetLossHosts))
}
// Test adding TCP hosts
mon.AddTCPHost("google.com:443")
mon.AddTCPHost("github.com:443")
if len(mon.tcpHosts) != 2 {
t.Errorf("Expected 2 TCP hosts, got %d", len(mon.tcpHosts))
}
}
// TestMinMaxAvgStd tests the statistics calculation function
func TestMinMaxAvgStd(t *testing.T) {
tests := []struct {
name string
data []float64
wantMin, wantMax, wantAvg float64
}{
{
name: "empty slice",
data: []float64{},
wantMin: 0, wantMax: 0, wantAvg: 0,
},
{
name: "single value",
data: []float64{5.0},
wantMin: 5.0, wantMax: 5.0, wantAvg: 5.0,
},
{
name: "multiple values",
data: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
wantMin: 1.0, wantMax: 5.0, wantAvg: 3.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
min, max, avg, _ := MinMaxAvgStd(tt.data)
if min != tt.wantMin {
t.Errorf("MinMaxAvgStd() min = %v, want %v", min, tt.wantMin)
}
if max != tt.wantMax {
t.Errorf("MinMaxAvgStd() max = %v, want %v", max, tt.wantMax)
}
if avg != tt.wantAvg {
t.Errorf("MinMaxAvgStd() avg = %v, want %v", avg, tt.wantAvg)
}
})
}
}
+87
View File
@@ -0,0 +1,87 @@
//go:build linux
// +build linux
package monitor
import tcell "github.com/gdamore/tcell/v2"
// Color 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),
}
)
// MeterColorForValue returns the appropriate color style for a meter value
// value: current value of the meter
// maxValue: maximum value of the meter
// reverse: if true, low values are good (green) and high values are bad (red)
//
// if false, high values are good (green) and low values are bad (red)
func MeterColorForValue(value, maxValue int, reverse bool) tcell.Style {
// Calculate percentage
percentage := float64(value) / float64(maxValue) * 100
// For reverse mode (low is good), invert the percentage
if reverse {
percentage = 100 - percentage
}
// Return color based on percentage (after any reversal)
// Now high percentage always means "good" and low percentage means "bad"
switch {
case percentage >= 90:
return CBrightGreen
case percentage >= 75:
return CGreen
case percentage >= 60:
return CDimGreen
case percentage >= 40:
return CYellow
case percentage >= 20:
return COrange
default:
return CRed
}
}
// StyleLatency returns the appropriate style for latency values
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
}
}
// StyleLoss returns the appropriate style for packet loss percentages
func StyleLoss(p float64) tcell.Style {
switch {
case p == 0:
return CBrightGreen
case p < 5:
return CYellow
default:
return CBrightRed
}
}
+105
View File
@@ -0,0 +1,105 @@
//go:build linux
// +build linux
package monitor
import (
"testing"
tcell "github.com/gdamore/tcell/v2"
)
// TestMeterColorForValue tests the MeterColorForValue function
func TestMeterColorForValue(t *testing.T) {
tests := []struct {
name string
value int
maxValue int
reverse bool
wantColor tcell.Style
}{
// Reverse mode tests (low is good, high is bad)
{
name: "reverse mode: 0% (best)",
value: 0,
maxValue: 10,
reverse: true,
wantColor: CBrightGreen,
},
{
name: "reverse mode: 10% (good)",
value: 1,
maxValue: 10,
reverse: true,
wantColor: CBrightGreen,
},
{
name: "reverse mode: 50% (medium)",
value: 5,
maxValue: 10,
reverse: true,
wantColor: CYellow,
},
{
name: "reverse mode: 90% (bad)",
value: 9,
maxValue: 10,
reverse: true,
wantColor: CRed,
},
{
name: "reverse mode: 100% (worst)",
value: 10,
maxValue: 10,
reverse: true,
wantColor: CRed,
},
// Normal mode tests (high is good, low is bad)
{
name: "normal mode: 0% (worst)",
value: 0,
maxValue: 10,
reverse: false,
wantColor: CRed,
},
{
name: "normal mode: 10% (bad)",
value: 1,
maxValue: 10,
reverse: false,
wantColor: CRed,
},
{
name: "normal mode: 50% (medium)",
value: 5,
maxValue: 10,
reverse: false,
wantColor: CYellow,
},
{
name: "normal mode: 90% (good)",
value: 9,
maxValue: 10,
reverse: false,
wantColor: CBrightGreen,
},
{
name: "normal mode: 100% (best)",
value: 10,
maxValue: 10,
reverse: false,
wantColor: CBrightGreen,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MeterColorForValue(tt.value, tt.maxValue, tt.reverse)
if got != tt.wantColor {
t.Errorf("MeterColorForValue(%d, %d, %v) = %v, want %v",
tt.value, tt.maxValue, tt.reverse, got, tt.wantColor)
}
})
}
}
+378
View File
@@ -0,0 +1,378 @@
//go:build linux
// +build linux
package monitor
import (
"context"
"fmt"
"sort"
"strings"
"time"
tcell "github.com/gdamore/tcell/v2"
)
// Frame counter for the timestamp spinner (ticks once per second)
var (
timestampSpinFrame = 0
lastTimestampSpinUpdate = time.Now()
)
// Put writes text to the screen at the specified position with style
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)
}
}
// HLine creates a horizontal line of the specified width
func HLine(w int) string { return strings.Repeat("=", w) }
// DrawRainbowText draws text with rainbow colors
func DrawRainbowText(scr tcell.Screen, x, y int, text string, colors []tcell.Style) {
for i, char := range text {
// Cycle through the rainbow colors
colorIndex := i % len(colors)
style := colors[colorIndex]
// Draw the character with the current rainbow color
scr.SetContent(x+i, y, char, nil, style)
}
}
// CreateMeter creates a visual meter using ASCII characters
func (m *Monitor) CreateMeter(value int) (string, tcell.Style) {
if value > m.MaxMeterValue {
value = m.MaxMeterValue
}
if value < 0 {
value = 0
}
// Calculate the number of fill characters to show
fillCount := value * m.MeterFillWidth / m.MaxMeterValue
if fillCount > m.MeterFillWidth {
fillCount = m.MeterFillWidth
}
// Build the meter string
var b strings.Builder
b.WriteRune(MeterStart)
// Add fill characters
for i := 0; i < fillCount; i++ {
b.WriteRune(MeterFill)
}
// Add empty spaces
for i := 0; i < m.MeterFillWidth-fillCount; i++ {
b.WriteRune(MeterEmpty)
}
b.WriteRune(MeterEnd)
// Get the appropriate style for this meter value
// Using reverse=true because for packet loss, low values are good
style := MeterColorForValue(value, m.MaxMeterValue, true)
return b.String(), style
}
// DrawInterface draws the interface status on the screen
func (m *Monitor) DrawInterface(scr tcell.Screen, y, w int, st *InterfaceStatus) int {
Put(scr, 0, y, HLine(w), CDefault)
// header line
healthy := st.IsHealthy(m.TCPTimeout)
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 := m.CreateMeter(meterValue)
// 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+m.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
}
// Get last drop time and age
dropTimeStr := "never"
dropAgeStr := "N/A"
if !st.LastDrop.IsZero() {
dropTimeStr = st.LastDrop.Format("15:04:05")
dropAgeStr = time.Since(st.LastDrop).Round(time.Second).String()
}
// Simplified reachability line without drop info
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)
}
}
// Sort for consistent display
sort.Strings(down)
// Add drop info to the end of the unreachable line
Put(scr, 0, y, fmt.Sprintf("Unreachable: %s (last drop at %s, age %s)",
strings.Join(down, ", "), dropTimeStr, dropAgeStr), CBrightRed)
}
y += 2
/* packet loss */
Put(scr, 0, y, "Packet Loss:", CDefault)
y++
// Get all hosts and sort them for consistent display order
var lossHosts []string
for host := range st.Loss {
lossHosts = append(lossHosts, host)
}
sort.Strings(lossHosts)
// Find the maximum length of host names for alignment
maxHostLen := 0
for _, host := range lossHosts {
if len(host) > maxHostLen {
maxHostLen = len(host)
}
}
// Add 1 for the colon
maxHostLen += 1
for _, host := range lossHosts {
p := st.Loss[host] * 100
// Use the maxHostLen for consistent alignment
Put(scr, 0, y, fmt.Sprintf("%-*s %5.0f%%", maxHostLen, host+":", 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",
m.HostWidth, "Host", m.NumWidth, "last", m.NumWidth, "min", m.NumWidth, "avg",
m.NumWidth, "max", m.StdWidth, "stddev", m.NWidth, "n", m.LostWidth, "lost")
Put(scr, 0, y, headerRow, CDefault)
y++
m.mu.RLock()
tcpHosts := append([]string{}, m.tcpHosts...)
m.mu.RUnlock()
for _, hp := range tcpHosts {
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",
m.HostWidth, hp,
m.NumWidth, fmt.Sprintf("%.0fms", last),
m.NumWidth, fmt.Sprintf("%.0fms", mi),
m.NumWidth, fmt.Sprintf("%.0fms", av),
m.NumWidth, fmt.Sprintf("%.0fms", ma),
m.StdWidth, fmt.Sprintf("%.0fms", sd),
m.NWidth, len(hist),
m.LostWidth, lost,
)
Put(scr, 0, y, row, CDefault)
// colourise individual numbers
Put(scr, m.HostWidth+1, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", last)), StyleLatency(last))
Put(scr, m.HostWidth+1+m.NumWidth+1, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", mi)), StyleLatency(mi))
Put(scr, m.HostWidth+1+m.NumWidth*2+2, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", av)), StyleLatency(av))
Put(scr, m.HostWidth+1+m.NumWidth*3+3, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", ma)), StyleLatency(ma))
Put(scr, m.HostWidth+1+m.NumWidth*4+4, y, fmt.Sprintf("%*s", m.StdWidth, fmt.Sprintf("%.0fms", sd)), CDefault)
// Colorize the lost packets column
lostStyle := CDefault
if lost > 0 {
lostStyle = CRed
}
Put(scr, m.HostWidth+1+m.NumWidth*4+4+m.StdWidth+1+m.NWidth+1, y, fmt.Sprintf("%*d", m.LostWidth, lost), lostStyle)
y++
}
y++
/* ICMP stats - combined into a single line with headers */
// Calculate total lost packets
lost := st.TotalICMPReq - st.TotalICMPRep
if lost < 0 {
lost = 0
}
// Create styles based on values
lostStyle := CDefault
if lost > 0 {
lostStyle = CRed
}
// Define column widths and positions
const valWidth = 9
// Format the values with right alignment
reqVal := fmt.Sprintf("%9d", st.TotalICMPReq)
repVal := fmt.Sprintf("%9d", st.TotalICMPRep)
lostVal := fmt.Sprintf("%9d", lost)
// Header texts with the same width as values for right alignment
reqHeader := fmt.Sprintf("%9s", "Requests")
repHeader := fmt.Sprintf("%9s", "Replies")
lostHeader := fmt.Sprintf("%9s", "Lost")
// Draw the header line
Put(scr, 0, y, " ", CDefault)
Put(scr, 8, y, reqHeader, CDefault)
Put(scr, 8+valWidth+4, y, repHeader, CDefault)
Put(scr, 8+2*(valWidth+4), y, lostHeader, CDefault)
y++
// Draw the values line
Put(scr, 0, y, "ICMP: ", CDefault)
Put(scr, 8, y, reqVal, CDefault)
Put(scr, 8+valWidth+4, y, repVal, CDefault)
Put(scr, 8+2*(valWidth+4), y, lostVal, lostStyle)
y += 2
st.mu.RUnlock()
return y
}
// uiLoop runs the UI event loop
func (m *Monitor) uiLoop(ctx context.Context) {
m.logf("UI loop started")
defer func() {
m.logf("UI loop cleanup")
m.screen.Clear()
m.screen.ShowCursor(0, 0)
m.screen.Fini()
m.logf("Screen finalized")
}()
// Load PST location
pstLoc, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
m.logf("Error loading PST location: %v", err)
pstLoc = time.UTC
}
// Function to draw the screen
drawScreen := func() {
w, _ := m.screen.Size()
m.screen.Clear()
// Draw the top horizontal line
Put(m.screen, 0, 0, HLine(w), CDefault)
// Get current time and format it
now := time.Now()
timeStr := now.Format(time.RFC1123Z)
// Format time in PST
pstTimeStr := now.In(pstLoc).Format(time.RFC1123Z)
// Update the timestamp spinner once per second
if time.Since(lastTimestampSpinUpdate) >= time.Second {
timestampSpinFrame = (timestampSpinFrame + 1) % len(BrailleSpins)
lastTimestampSpinUpdate = now
}
// Get braille spinner character
brailleChar := BrailleSpins[timestampSpinFrame]
// Draw the header with timestamp and spinner
Put(m.screen, 0, 1, "== ", CDefault)
Put(m.screen, 3, 1, string(brailleChar)+" ", CDefault)
// Draw the timestamp with rainbow colors
DrawRainbowText(m.screen, 5, 1, timeStr, CRainbow)
// Add 5 space gap and PST time with rainbow colors
DrawRainbowText(m.screen, 5+len(timeStr)+5, 1, pstTimeStr, CRainbow)
// Draw the bottom horizontal line
Put(m.screen, 0, 2, HLine(w), CDefault)
// Draw the runtime
Put(m.screen, 0, 3, "Runtime: "+time.Since(m.startTime).Round(time.Second).String(), CDefault)
// Draw interfaces
y := 5
y = m.DrawInterface(m.screen, y, w, m.interfaceA)
_ = m.DrawInterface(m.screen, y, w, m.interfaceB)
// Show the screen
m.screen.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():
m.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()
}
}
}