check / check (push) Successful in 2m0s
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.
417 lines
11 KiB
Go
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()
|
|
}
|
|
}
|
|
}
|