Files
rtnetmon/internal/monitor/ui.go
T
clawbot 486a47397c
check / check (push) Failing after 0s
Detect interfaces per platform; add macOS and single-interface support (closes #2)
Linux runs exactly as before when both bridge interfaces exist. When they do
not, the lone default-route interface is monitored, and a single interface
draws a single UI pane instead of an empty second one.

macOS is newly supported: a running VPN tunnel (utun) is monitored as the
primary pane alongside the physical default-route interface, or the physical
interface alone when no VPN is up.

Detection lives in internal/netdetect: interface/route data types, pure
selection logic keyed on OS name, and route parsers, all unit-tested on Linux
for both platforms. Only the real route query and the per-platform TCP dial
binding are build-tagged. NewMonitor now takes a list of interfaces.

Not verified on a real mac: live netstat parsing, IP_BOUND_IF dialing, Mullvad leak protection.

Model: opus-4-8 (implementation); fable-5-1 (landing commit)
2026-09-21 15:01:59 +02:00

411 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)
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.
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")
}()
pstLoc, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
m.logf("Error loading PST location: %v", err)
pstLoc = time.UTC
}
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 := now.Format(time.RFC1123Z)
pstTimeStr := now.In(pstLoc).Format(time.RFC1123Z)
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,
pstTimeStr, 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()
}
}
}