// Package monitor implements the real-time network monitoring dashboard: // ICMP reachability, packet loss, TCP latency, and the terminal UI that // renders them. It monitors one or two interfaces. package monitor import ( "context" "encoding/json" "errors" "fmt" "math" "net" "os" "os/exec" "runtime" "strconv" "strings" "sync" "time" tcell "github.com/gdamore/tcell/v2" ) // Meter glyphs used to render the ASCII loss meter. const ( MeterStart = '[' MeterEnd = ']' MeterFill = '=' MeterEmpty = ' ' ) // Default monitor timing configuration. const ( defaultICMPTimeout = 500 * time.Millisecond defaultTCPTimeout = 500 * time.Millisecond defaultPacketLossPings = 20 defaultPacketLossPeriod = 5 * time.Second defaultStatsHistory = 300 defaultScreenRefresh = 500 * time.Millisecond ) // Default display geometry (column widths and meter sizing). const ( defaultMeterWidth = 7 defaultMeterFillWidth = 5 defaultMaxMeterValue = 10 defaultHostWidth = 30 defaultNumWidth = 7 defaultStdWidth = 8 defaultNWidth = 6 defaultLostWidth = 7 ) // Miscellaneous timing constants. const ( ipInfoTimeout = 2 * time.Second lossQueryTimeout = 3 * time.Second uiUpdateBuffer = 100 logFileMode = 0o644 ) // errNoIPv4 is returned when an interface has no usable IPv4 address. var errNoIPv4 = errors.New("no IPv4 address on interface") // 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 to monitor (one or two) interfaces []*InterfaceStatus // Logging logFile string // Runtime state screen tcell.Screen startTime time.Time uiUpdate chan struct{} // Synchronization mu sync.RWMutex } // IfaceSpec names one interface to monitor and its display label. type IfaceSpec struct { Name string Label string } // NewMonitor creates a new Monitor instance with default settings, // monitoring the given interfaces (one or two). func NewMonitor(ifaces []IfaceSpec, logFile string) *Monitor { m := &Monitor{ ICMPTimeout: defaultICMPTimeout, TCPTimeout: defaultTCPTimeout, PacketLossPings: defaultPacketLossPings, PacketLossPeriod: defaultPacketLossPeriod, StatsHistory: defaultStatsHistory, ScreenRefresh: defaultScreenRefresh, MeterWidth: defaultMeterWidth, MeterFillWidth: defaultMeterFillWidth, MaxMeterValue: defaultMaxMeterValue, HostWidth: defaultHostWidth, NumWidth: defaultNumWidth, StdWidth: defaultStdWidth, NWidth: defaultNWidth, LostWidth: defaultLostWidth, reachabilityHosts: []string{}, packetLossHosts: []string{}, tcpHosts: []string{}, logFile: logFile, startTime: time.Now(), uiUpdate: make(chan struct{}, uiUpdateBuffer), } for _, spec := range ifaces { m.interfaces = append(m.interfaces, NewInterfaceStatus(spec.Name, spec.Label)) } return m } // 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") 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) } err = scr.Init() if err != nil { m.logf("Error initializing screen: %v", err) return fmt.Errorf("error initializing screen: %w", err) } m.screen = scr m.logf("Screen initialized") ctx, cancel := context.WithCancel(ctx) defer cancel() go m.keyboardEventLoop(ctx, cancel) 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() for _, st := range m.interfaces { go m.reachLoop(ctx, st, reachHosts) go m.lossLoop(ctx, st, lossHosts) go m.tcpLoop(ctx, st, tcpHosts) } m.logf("Starting UI loop") m.uiLoop(ctx) m.logf("UI loop exited, monitor ending") return nil } // notifyUI requests a screen redraw without blocking if one is pending. func (m *Monitor) notifyUI() { select { case m.uiUpdate <- struct{}{}: default: } } // keyboardEventLoop handles keyboard input. The context is accepted for a // uniform loop signature; shutdown is driven by cancel on the quit key and // by the screen being finalized (PollEvent then returns nil). func (m *Monitor) keyboardEventLoop(_ context.Context, cancel context.CancelFunc) { m.logf("Starting keyboard event loop") for { ev := m.screen.PollEvent() if ev == nil { continue } 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 } } m.notifyUI() } } // logf logs a formatted message. func (m *Monitor) logf(format string, v ...any) { 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 ipinfo.io response. type ipInfoResp struct { IP string `json:"ip"` Hostname string `json:"hostname"` Org string `json:"org"` } // fetchIPInfo fetches public IP information as seen from an interface. func fetchIPInfo(iface string) string { ctx, cancel := context.WithTimeout(context.Background(), ipInfoTimeout) defer cancel() cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io") out, _ := cmd.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) } // pingArgs builds the arguments for a single reachability ping on the given // OS. Linux binds the interface with -I and takes -W in seconds; macOS binds // with -b and takes -W in milliseconds. func pingArgs(goos, iface, host string) []string { if goos == "darwin" { return []string{"-b", iface, "-c1", "-W1000", host} } return []string{"-I", iface, "-c1", "-W1", host} } // lossArgs builds the arguments for a packet-loss ping burst of count pings. func lossArgs(goos, iface, host string, count int) []string { c := strconv.Itoa(count) if goos == "darwin" { return []string{"-q", "-i", "0.05", "-c", c, "-W1000", "-b", iface, host} } return []string{"-q", "-i", "0.05", "-c", c, "-W1", "-I", iface, host} } // pingOnce performs a single ping over the named interface. func (m *Monitor) pingOnce(ctx context.Context, iface, host string) bool { ctx, cancel := context.WithTimeout(ctx, m.ICMPTimeout) defer cancel() args := pingArgs(runtime.GOOS, iface, host) cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input ctx, "ping", args...) return cmd.Run() == nil } // lossPercent measures the packet loss fraction (0..1) for a host. func (m *Monitor) lossPercent(ctx context.Context, iface, host string) float64 { ctx, cancel := context.WithTimeout(ctx, lossQueryTimeout) defer cancel() args := lossArgs(runtime.GOOS, iface, host, m.PacketLossPings) cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input ctx, "ping", args...) out, err := cmd.CombinedOutput() if err != nil { return 1.0 } for _, ln := range strings.Split(string(out), "\n") { if !strings.Contains(ln, "packet loss") { continue } for _, f := range strings.Fields(ln) { before, ok := strings.CutSuffix(f, "%") if !ok { continue } p, _ := strconv.ParseFloat(before, 64) return p / percentFull } } return 1.0 } // localAddr returns the first IPv4 address bound to an interface. func localAddr(iface string) (net.Addr, error) { ifi, err := net.InterfaceByName(iface) if err != nil { return nil, fmt.Errorf("interface %s: %w", iface, 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("%w: %s", errNoIPv4, iface) } // tcpDuration measures how long a TCP connection to hp takes over iface. 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, Control: bindControl(iface)} st := time.Now() c, err := d.Dial("tcp", hp) if err != nil { return m.TCPTimeout } _ = c.Close() return time.Since(st) } // MinMaxAvgStd returns the minimum, maximum, mean and standard deviation. func MinMaxAvgStd(xs []float64) (float64, float64, float64, float64) { if len(xs) == 0 { return 0, 0, 0, 0 } mn, mx := xs[0], xs[0] var sum float64 for _, v := range xs { mn = min(mn, v) mx = max(mx, v) sum += v } avg := sum / float64(len(xs)) var variance float64 for _, v := range xs { variance += (v - avg) * (v - avg) } return mn, mx, avg, math.Sqrt(variance / float64(len(xs))) } // Spin advances the spinner frame. func (st *InterfaceStatus) Spin() { st.SpinFrame = (st.SpinFrame + 1) % len(spins()) } // IsHealthy reports whether the interface currently looks healthy. func (st *InterfaceStatus) IsHealthy(tcpTimeout time.Duration) bool { st.mu.RLock() defer st.mu.RUnlock() for _, ok := range st.Reachable { if !ok { return false } } for _, lp := range st.Loss { if lp > 0 { return false } } for _, hist := range st.TCP { if len(hist) == 0 || hist[len(hist)-1] >= float64(tcpTimeout.Milliseconds()) { return false } } return true } // spins returns the ASCII spinner frames. func spins() []rune { return []rune{'|', '/', '-', '\\'} } // brailleSpins returns the braille clock spinner frames. func brailleSpins() []rune { return []rune{'⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋'} } // Logf appends a timestamped formatted message to logFile, if set. func Logf(logFile, format string, v ...any) { if logFile == "" { return } f, err := os.OpenFile( //nolint:gosec // G304: operator --logfile path logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, logFileMode) if err != nil { return } defer func() { _ = f.Close() }() _, _ = fmt.Fprintf(f, time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...) }