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