Files
rtnetmon/internal/monitor/ui.go
T
sneak bbceab0b24
check / check (push) Successful in 2m0s
Show UTC as the first header clock instead of hardcoded Los Angeles time
The header drew the host's local time and then America/Los_Angeles
time, so on a Mac in that zone both clocks read the same and neither
was UTC. The first clock is now UTC and the second the host's local
time, which also drops the hardcoded zone and its load-failure path.
2026-11-06 19:12:41 -08:00

417 lines
11 KiB
Go

package monitor
import (
"context"
"fmt"
"sort"
"strings"
"time"
tcell "github.com/gdamore/tcell/v2"
)
// Fixed rows at the top of the display.
const (
rowTopRule = 0
rowClock = 1
rowBottomRule = 2
rowRuntime = 3
rowFirstIface = 5
)
// Column offsets and spacing within the drawn output.
const (
colLeft = 0
colSpinner = 3 // after the "== " prefix
colMeter = 5 // after the spinner
colGap = 1 // single-space gap between fields
clockGap = 5 // space between the two clocks
lineStep = 1
blockGap = 2 // blank line plus the following line
headerAdvance = 4 // rule + content + rule + blank line
)
// ICMP summary table geometry.
const (
icmpLabelWidth = 8
icmpValWidth = 9
icmpColGap = 4
)
// 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 returns a horizontal line of the specified width.
func HLine(w int) string { return strings.Repeat("=", w) }
// DrawRainbowText draws text cycling through the given color styles.
func DrawRainbowText(scr tcell.Screen, x, y int, text string, colors []tcell.Style) {
for i, char := range text {
style := colors[i%len(colors)]
scr.SetContent(x+i, y, char, nil, style)
}
}
// msStr formats a millisecond value as a compact "%.0fms" string.
func msStr(v float64) string {
return fmt.Sprintf("%.0fms", v)
}
// CreateMeter creates a visual meter using ASCII characters.
func (m *Monitor) CreateMeter(value int) (string, tcell.Style) {
value = max(min(value, m.MaxMeterValue), 0)
fillCount := min(value*m.MeterFillWidth/m.MaxMeterValue, m.MeterFillWidth)
var b strings.Builder
b.WriteRune(MeterStart)
for range fillCount {
b.WriteRune(MeterFill)
}
for range m.MeterFillWidth - fillCount {
b.WriteRune(MeterEmpty)
}
b.WriteRune(MeterEnd)
// reverse=true: 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, returning the next
// free row.
func (m *Monitor) DrawInterface(scr tcell.Screen, y, w int, st *InterfaceStatus) int {
Put(scr, colLeft, y, HLine(w), tcell.StyleDefault)
healthy := st.IsHealthy(m.TCPTimeout)
y = m.drawHeader(scr, y, w, st, healthy)
m.mu.RLock()
tcpHosts := append([]string{}, m.tcpHosts...)
m.mu.RUnlock()
st.mu.RLock()
defer st.mu.RUnlock()
y = drawReachability(scr, y, st)
y = drawPacketLoss(scr, y, st)
y = m.drawTCPTable(scr, y, st, tcpHosts)
y = drawICMPStats(scr, y, st)
// The Starlink lines belong to the physical (non-VPN gateway) pane only.
if st == m.slPane {
y = m.drawStarlink(scr, y)
}
return y
}
// drawHeader renders the interface title, spinner and meter line.
func (m *Monitor) drawHeader(
scr tcell.Screen, y, w int, st *InterfaceStatus, healthy bool,
) int {
style := styleBrightGreen()
if !healthy {
style = styleBrightRed()
}
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()
meter, meterStyle := m.CreateMeter(meterValue)
colors := rainbow()
spinnerStyle := colors[spinnerFrame%len(colors)]
Put(scr, colLeft, y+lineStep, "== ", tcell.StyleDefault)
Put(scr, colSpinner, y+lineStep, string(spinChar)+" ", spinnerStyle)
Put(scr, colMeter, y+lineStep, meter+" ", meterStyle)
Put(scr, colMeter+m.MeterWidth+colGap, y+lineStep, header, style)
Put(scr, colLeft, y+blockGap, HLine(w), tcell.StyleDefault)
return y + headerAdvance
}
// drawReachability renders the reachability summary. The caller holds
// st.mu.RLock.
func drawReachability(scr tcell.Screen, y int, st *InterfaceStatus) int {
total := len(st.Reachable)
good := 0
for _, ok := range st.Reachable {
if ok {
good++
}
}
age := time.Since(st.LastPing).Round(time.Second)
reachStyle := styleBrightGreen()
if good != total {
reachStyle = styleBrightRed()
}
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()
}
Put(scr, colLeft, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)",
good, total, st.LastPing.Format("15:04:05"), age), reachStyle)
y += lineStep
if good == total {
Put(scr, colLeft, y, "Unreachable: none", tcell.StyleDefault)
} else {
down := make([]string, 0, len(st.Reachable))
for h, ok := range st.Reachable {
if !ok {
down = append(down, h)
}
}
sort.Strings(down)
Put(scr, colLeft, y, fmt.Sprintf("Unreachable: %s (last drop at %s, age %s)",
strings.Join(down, ", "), dropTimeStr, dropAgeStr), styleBrightRed())
}
return y + blockGap
}
// drawPacketLoss renders the per-host packet-loss list. The caller holds
// st.mu.RLock.
func drawPacketLoss(scr tcell.Screen, y int, st *InterfaceStatus) int {
Put(scr, colLeft, y, "Packet Loss:", tcell.StyleDefault)
y += lineStep
lossHosts := make([]string, 0, len(st.Loss))
for host := range st.Loss {
lossHosts = append(lossHosts, host)
}
sort.Strings(lossHosts)
maxHostLen := 0
for _, host := range lossHosts {
maxHostLen = max(maxHostLen, len(host))
}
maxHostLen++ // room for the colon
for _, host := range lossHosts {
p := st.Loss[host] * percentFull
Put(scr, colLeft, y, fmt.Sprintf("%-*s %5.0f%%", maxHostLen, host+":", p),
StyleLoss(p))
y += lineStep
}
dAge := "N/A"
if !st.LastDrop.IsZero() {
dAge = time.Since(st.LastDrop).Round(time.Second).String()
}
Put(scr, colLeft, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)",
st.DroppedCount, st.LastDrop.Format("15:04:05"), dAge), tcell.StyleDefault)
return y + blockGap
}
// drawTCPTable renders the TCP connect-stats table. The caller holds
// st.mu.RLock.
func (m *Monitor) drawTCPTable(
scr tcell.Screen, y int, st *InterfaceStatus, tcpHosts []string,
) int {
Put(scr, colLeft, y, "TCP Connect Stats:", tcell.StyleDefault)
y += lineStep
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, colLeft, y, headerRow, tcell.StyleDefault)
y += lineStep
for _, hp := range tcpHosts {
hist := st.TCP[hp]
if len(hist) == 0 {
continue
}
m.drawTCPRow(scr, y, st, hp, hist)
y += lineStep
}
return y + lineStep
}
// drawTCPRow renders one TCP host's statistics row. The caller holds
// st.mu.RLock.
func (m *Monitor) drawTCPRow(
scr tcell.Screen, y int, st *InterfaceStatus, hp string, hist []float64,
) {
last := hist[len(hist)-1]
mi, ma, av, sd := MinMaxAvgStd(hist)
host := strings.Split(hp, ":")[0]
lost := st.LostPackets[host]
row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d %*d",
m.HostWidth, hp,
m.NumWidth, msStr(last),
m.NumWidth, msStr(mi),
m.NumWidth, msStr(av),
m.NumWidth, msStr(ma),
m.StdWidth, msStr(sd),
m.NWidth, len(hist),
m.LostWidth, lost,
)
Put(scr, colLeft, y, row, tcell.StyleDefault)
// Overlay the numeric columns colored by value, at the same offsets the
// base row above laid them out.
x := m.HostWidth + colGap
Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(last)), StyleLatency(last))
x += m.NumWidth + colGap
Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(mi)), StyleLatency(mi))
x += m.NumWidth + colGap
Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(av)), StyleLatency(av))
x += m.NumWidth + colGap
Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(ma)), StyleLatency(ma))
x += m.NumWidth + colGap
Put(scr, x, y, fmt.Sprintf("%*s", m.StdWidth, msStr(sd)), tcell.StyleDefault)
x += m.StdWidth + colGap
x += m.NWidth + colGap // n column already drawn by the base row
lostStyle := tcell.StyleDefault
if lost > 0 {
lostStyle = styleRed()
}
Put(scr, x, y, fmt.Sprintf("%*d", m.LostWidth, lost), lostStyle)
}
// drawICMPStats renders the ICMP request/reply/lost summary. The caller
// holds st.mu.RLock.
func drawICMPStats(scr tcell.Screen, y int, st *InterfaceStatus) int {
lost := max(st.TotalICMPReq-st.TotalICMPRep, 0)
lostStyle := tcell.StyleDefault
if lost > 0 {
lostStyle = styleRed()
}
reqCol := icmpLabelWidth
repCol := reqCol + icmpValWidth + icmpColGap
lostCol := repCol + icmpValWidth + icmpColGap
Put(scr, reqCol, y, fmt.Sprintf("%*s", icmpValWidth, "Requests"), tcell.StyleDefault)
Put(scr, repCol, y, fmt.Sprintf("%*s", icmpValWidth, "Replies"), tcell.StyleDefault)
Put(scr, lostCol, y, fmt.Sprintf("%*s", icmpValWidth, "Lost"), tcell.StyleDefault)
y += lineStep
reqStr := fmt.Sprintf("%*d", icmpValWidth, st.TotalICMPReq)
repStr := fmt.Sprintf("%*d", icmpValWidth, st.TotalICMPRep)
lostStr := fmt.Sprintf("%*d", icmpValWidth, lost)
Put(scr, colLeft, y, "ICMP: ", tcell.StyleDefault)
Put(scr, reqCol, y, reqStr, tcell.StyleDefault)
Put(scr, repCol, y, repStr, tcell.StyleDefault)
Put(scr, lostCol, y, lostStr, lostStyle)
return y + blockGap
}
// uiLoop runs the UI event loop.
// clockFormat is the layout of both header clocks.
const clockFormat = time.RFC1123Z
// clockStrings renders the header clocks: UTC first, then the host's local
// time in whatever location now carries.
func clockStrings(now time.Time) (string, string) {
return now.UTC().Format(clockFormat), now.Format(clockFormat)
}
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")
}()
timestampSpinFrame := 0
lastTimestampSpinUpdate := time.Now()
drawScreen := func() {
w, _ := m.screen.Size()
m.screen.Clear()
Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault)
now := time.Now()
timeStr, localTimeStr := clockStrings(now)
if time.Since(lastTimestampSpinUpdate) >= time.Second {
timestampSpinFrame = (timestampSpinFrame + 1) % len(brailleSpins())
lastTimestampSpinUpdate = now
}
brailleChar := brailleSpins()[timestampSpinFrame]
colors := rainbow()
Put(m.screen, colLeft, rowClock, "== ", tcell.StyleDefault)
Put(m.screen, colSpinner, rowClock, string(brailleChar)+" ", tcell.StyleDefault)
DrawRainbowText(m.screen, colMeter, rowClock, timeStr, colors)
DrawRainbowText(m.screen, colMeter+len(timeStr)+clockGap, rowClock,
localTimeStr, colors)
Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault)
runtime := time.Since(m.startTime).Round(time.Second).String()
Put(m.screen, colLeft, rowRuntime, "Runtime: "+runtime, tcell.StyleDefault)
// Draw one pane per interface; a single interface draws one pane.
y := rowFirstIface
for _, st := range m.interfaces {
y = m.DrawInterface(m.screen, y, w, st)
}
m.screen.Show()
}
drawScreen()
backupTicker := time.NewTicker(time.Second)
defer backupTicker.Stop()
for {
select {
case <-ctx.Done():
m.logf("Context cancelled, exiting UI loop")
return
case <-m.uiUpdate:
drawScreen()
case <-backupTicker.C:
drawScreen()
}
}
}