//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...) }