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 (one pane each; a single interface draws one pane) y := 5 for _, st := range m.interfaces { y = m.DrawInterface(m.screen, y, w, st) } // 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() } } }