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
+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()
}
}
}