New maglevt TUI component: out-of-band VIP health monitor
A small bubbletea TUI that reads maglev.yaml (repeatable --config), enumerates every VIP, and probes each from outside the load balancer on a tight cadence (default 100ms, ±10% jitter). HTTP/HTTPS VIPs get a GET against a configurable URI (default /.well-known/ipng/healthz) with per-VIP rolling latency (p50/p95/p99/max), lifetime N/FAIL counters, LAST status, and a response-header tally. Non-HTTP VIPs get a TCP connect probe. A bounded error panel classifies anomalies as timeout / http-err / net-err / spike and auto-sizes to fill the screen. Utility: during a failover drill (backend flap, AS drain, config push) the tally panel shows which backend each VIP is actually steering to, with two-colour activity highlighting over a 5s window — white = receiving traffic, grey = drained. Paired with the rolling OK%/latency columns it gives an at-a-glance answer to "is the VIP healthy from the outside right now, and which backend is it hitting", without relying on maglevd's own view of the world. Also bumps Makefile/go.mod to build the new binary.
This commit is contained in:
724
cmd/tester/view.go
Normal file
724
cmd/tester/view.go
Normal file
@@ -0,0 +1,724 @@
|
||||
// Copyright (c) 2026, Pim van Pelt <pim@ipng.ch>
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Styles. Colours are ANSI 256 indices so maglevt renders the same
|
||||
// across iTerm, Alacritty, xterm, tmux, and screen without depending
|
||||
// on truecolor support.
|
||||
var (
|
||||
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14"))
|
||||
styleSection = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14"))
|
||||
styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
styleHint = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||
|
||||
styleOK = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
|
||||
styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true)
|
||||
styleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true)
|
||||
styleWhite = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true)
|
||||
|
||||
styleHTTP = lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
|
||||
styleTCP = lipgloss.NewStyle().Foreground(lipgloss.Color("13"))
|
||||
|
||||
styleRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true)
|
||||
stylePaused = lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Bold(true)
|
||||
)
|
||||
|
||||
// View renders the full TUI. One call per redraw; re-invoked whenever
|
||||
// bubbletea dispatches a message. No mutation — every field it reads
|
||||
// is in Model or vipState, so it's safe to call concurrently with
|
||||
// Update's message handling (tea serialises them anyway).
|
||||
//
|
||||
// Section order: header → probe table → tally (if --header collected
|
||||
// any samples) → events panel (if any anomalies have been observed)
|
||||
// → [blank padding] → footer. The tally lives above the events so a
|
||||
// growing events list pushes itself against the footer rather than
|
||||
// shoving the tally up and down every time a new anomaly lands —
|
||||
// the tally is the panel the operator stares at during a failover
|
||||
// test, and it should stay put.
|
||||
//
|
||||
// The footer is pinned to the last row of the terminal: we count
|
||||
// how many lines the above sections produced, then pad with enough
|
||||
// blank lines to push the footer to row m.height. That keeps the
|
||||
// `[q] quit …` hint visible at the bottom even when the content
|
||||
// doesn't fill the screen, and matches the convention most TUIs
|
||||
// follow. When the content DOES overflow (tiny terminal / huge
|
||||
// config), we fall back to a single blank line between content
|
||||
// and footer so the footer is still reachable by scrolling.
|
||||
func (m Model) View() string {
|
||||
if m.help {
|
||||
return m.viewHelp()
|
||||
}
|
||||
|
||||
// Phase 1: build everything that isn't the events panel, so we
|
||||
// can measure how many rows are left for events. This is the
|
||||
// "fixed" content — header, table, and (maybe) tally. Events
|
||||
// then absorb whatever space remains between this block and
|
||||
// the screen-pinned footer.
|
||||
var pre strings.Builder
|
||||
pre.WriteString(m.viewHeader())
|
||||
pre.WriteString("\n\n")
|
||||
pre.WriteString(m.viewTable())
|
||||
if m.opts.Header != "" && m.anyTallied() {
|
||||
pre.WriteString("\n")
|
||||
pre.WriteString(m.viewTally())
|
||||
}
|
||||
preContent := pre.String()
|
||||
|
||||
footer := m.viewFooter()
|
||||
|
||||
// Phase 2: compute how many event rows can fit.
|
||||
//
|
||||
// Screen budget:
|
||||
// preContent lines (header + table + optional tally)
|
||||
// + 1 blank separator
|
||||
// + 1 events section header
|
||||
// + N events rows ← the unknown we're solving for
|
||||
// + M footer-pad blank rows
|
||||
// + 1 footer line
|
||||
// = m.height
|
||||
//
|
||||
// Solving for max N (before the footer-pad term kicks in):
|
||||
// N = m.height - preLines - 3 (3 = separator + events hdr + footer)
|
||||
//
|
||||
// When there's no room for events (tiny terminal, or tally
|
||||
// already fills the screen), maxEvents clamps to 0 and the
|
||||
// events section is skipped entirely, letting the footer pad
|
||||
// logic below handle the spacing.
|
||||
preLines := strings.Count(preContent, "\n")
|
||||
// Budget arithmetic for the events panel:
|
||||
// preLines + 1 (separator) + 1 (events hdr) + maxEvents + 1 (footer) = m.height
|
||||
// So maxEvents = m.height - preLines - 3. A negative value
|
||||
// means the terminal is too tight to even frame the panel
|
||||
// (section header + separator + footer wouldn't fit), in
|
||||
// which case we skip the whole section. A zero value means
|
||||
// we render only the "Recent events: (none)" placeholder
|
||||
// with no rows below it — enough to mark the panel's
|
||||
// position on the screen during the quiet period before the
|
||||
// first anomaly arrives.
|
||||
maxEvents := -1
|
||||
if m.height > 0 {
|
||||
maxEvents = m.height - preLines - 3
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(preContent)
|
||||
if maxEvents >= 0 {
|
||||
content.WriteString("\n")
|
||||
content.WriteString(m.viewEvents(maxEvents))
|
||||
}
|
||||
contentStr := content.String()
|
||||
|
||||
// Phase 3: pin footer to last screen row. Alt-screen guarantees
|
||||
// the view starts at row 1, so we need (m.height - 1) lines of
|
||||
// content above the footer. m.height == 0 means no
|
||||
// WindowSizeMsg yet (first frame); degrade to a single blank
|
||||
// separator so the footer is still visible even if the exact
|
||||
// row is wrong.
|
||||
if m.height <= 0 {
|
||||
return contentStr + "\n\n" + footer
|
||||
}
|
||||
// contentStr ends with a newline, so strings.Count is exactly the
|
||||
// number of visible rows it occupies, and the cursor is parked at
|
||||
// row contentLines+1 after it's written. To land the footer on
|
||||
// the last terminal row we need padLines = m.height-contentLines-1
|
||||
// extra newlines between content and footer. padLines==0 is the
|
||||
// perfect-fit case (events panel was sized with this in mind) and
|
||||
// must NOT be bumped to 1 — that would push the footer to row
|
||||
// m.height+1 and scroll the header off the top of the alt-screen.
|
||||
contentLines := strings.Count(contentStr, "\n")
|
||||
padLines := m.height - contentLines - 1
|
||||
if padLines < 0 {
|
||||
padLines = 0
|
||||
}
|
||||
return contentStr + strings.Repeat("\n", padLines) + footer
|
||||
}
|
||||
|
||||
func (m Model) viewHeader() string {
|
||||
runState := styleRunning.Render("RUNNING")
|
||||
if paused.Load() {
|
||||
runState = stylePaused.Render("PAUSED")
|
||||
}
|
||||
line := fmt.Sprintf(
|
||||
"maglevt — %s — interval: %s timeout: %s header: %s [%s] uptime: %s",
|
||||
m.cfgPath,
|
||||
m.opts.Interval,
|
||||
m.opts.Timeout,
|
||||
m.opts.Header,
|
||||
runState,
|
||||
time.Since(m.startAt).Round(time.Second),
|
||||
)
|
||||
return styleHeader.Render(line)
|
||||
}
|
||||
|
||||
// Table column widths (visible characters, not bytes). All cell
|
||||
// rendering goes through padVisibleLeft / padVisibleRight which
|
||||
// measure width via lipgloss.Width — that strips ANSI escape
|
||||
// sequences before counting, so ANSI-styled cells pad correctly
|
||||
// instead of under-counting by the escape-code overhead.
|
||||
//
|
||||
// There's no VIP-name column: identity is the (scheme, ip, port)
|
||||
// tuple. PROTO + ADDR together are the row key, which also makes
|
||||
// the display independent of whatever names the source yaml files
|
||||
// used (potentially conflicting across a multi-config union).
|
||||
const (
|
||||
colSchemeW = 5 // "http", "https", "tcp" — widest is 5
|
||||
colAFW = 2 // "v4" / "v6"
|
||||
colAddrW = 40 // address + port, bracketed IPv6; full 8-group expansion fits
|
||||
colLastW = 10
|
||||
colNW = 7 // "N" lifetime probe count; 7 digits handles >24h @ 100ms
|
||||
colFailW = 6 // "FAIL" lifetime failure count
|
||||
colOKW = 7
|
||||
colP50W = 9
|
||||
colP95W = 9
|
||||
colP99W = 9
|
||||
colMaxW = 9
|
||||
)
|
||||
|
||||
// padVisibleRight right-pads s with spaces so that its rendered
|
||||
// visible width (lipgloss.Width, which strips ANSI) matches width.
|
||||
// If s is already that wide or wider, it's returned unchanged. Used
|
||||
// for left-aligned columns whose content may contain ANSI escapes.
|
||||
func padVisibleRight(s string, width int) string {
|
||||
w := lipgloss.Width(s)
|
||||
if w >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-w)
|
||||
}
|
||||
|
||||
// padVisibleLeft is the right-aligned sibling of padVisibleRight.
|
||||
func padVisibleLeft(s string, width int) string {
|
||||
w := lipgloss.Width(s)
|
||||
if w >= width {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat(" ", width-w) + s
|
||||
}
|
||||
|
||||
// truncateVisible clamps s to at most width *visible* characters,
|
||||
// preserving embedded ANSI escape sequences by copying runs between
|
||||
// escapes. A single ellipsis replaces the last visible character
|
||||
// when truncation happens so the operator can see the cell was cut.
|
||||
func truncateVisible(s string, width int) string {
|
||||
if lipgloss.Width(s) <= width {
|
||||
return s
|
||||
}
|
||||
if width <= 0 {
|
||||
return ""
|
||||
}
|
||||
// Walk runes, copying ANSI escape sequences verbatim (they
|
||||
// don't consume visible width) and counting printable runes.
|
||||
var b strings.Builder
|
||||
visible := 0
|
||||
inEscape := false
|
||||
for _, r := range s {
|
||||
if inEscape {
|
||||
b.WriteRune(r)
|
||||
if r == 'm' {
|
||||
inEscape = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r == 0x1b {
|
||||
b.WriteRune(r)
|
||||
inEscape = true
|
||||
continue
|
||||
}
|
||||
if visible+1 >= width {
|
||||
b.WriteRune('…')
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
visible++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) viewTable() string {
|
||||
var b strings.Builder
|
||||
// Header row: plain text (no per-cell styling) so the column
|
||||
// widths match the data rows 1:1 without lipgloss.Width
|
||||
// gymnastics.
|
||||
// Header labels must each fit within their column width — "PROTO"
|
||||
// at 6 chars overflows colSchemeW (5) and would push every
|
||||
// subsequent header one column right of its data, so we use the
|
||||
// 5-char "PROTO" here. LAST is left-aligned to match the starting
|
||||
// column of the first tally entry on the row below, so the operator
|
||||
// can eye-align a status code with the backend it corresponds to.
|
||||
header := " " +
|
||||
padVisibleRight("PROTO", colSchemeW) + " " +
|
||||
padVisibleRight("AF", colAFW) + " " +
|
||||
padVisibleRight("ADDR", colAddrW) + " " +
|
||||
padVisibleRight("LAST", colLastW) + " " +
|
||||
padVisibleLeft("N", colNW) + " " +
|
||||
padVisibleLeft("FAIL", colFailW) + " " +
|
||||
padVisibleLeft("OK%", colOKW) + " " +
|
||||
padVisibleLeft("p50", colP50W) + " " +
|
||||
padVisibleLeft("p95", colP95W) + " " +
|
||||
padVisibleLeft("p99", colP99W) + " " +
|
||||
padVisibleLeft("max", colMaxW)
|
||||
b.WriteString(styleDim.Render(header))
|
||||
b.WriteString("\n")
|
||||
for _, v := range m.vips {
|
||||
b.WriteString(m.viewRow(v))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) viewRow(v *vipState) string {
|
||||
scheme := schemeLabel(v.info.scheme)
|
||||
addr := truncateVisible(m.displayAddr(v), colAddrW)
|
||||
last := lastCell(v)
|
||||
|
||||
nStr := fmt.Sprintf("%d", v.totalProbes)
|
||||
failStr := fmt.Sprintf("%d", v.totalFails)
|
||||
// Red-tint the FAIL column only when there's actually been a
|
||||
// failure. Zero reads as "fine" so it stays in the dim default
|
||||
// colour along with a green-zero N for consistency. styleDim
|
||||
// on the counters is intentional — they're reference values,
|
||||
// not the primary "is this VIP healthy" signal (that's LAST
|
||||
// and OK%).
|
||||
nStyled := styleDim.Render(nStr)
|
||||
var failStyled string
|
||||
if v.totalFails > 0 {
|
||||
failStyled = styleErr.Render(failStr)
|
||||
} else {
|
||||
failStyled = styleDim.Render(failStr)
|
||||
}
|
||||
|
||||
okStr, p50Str, p95Str, p99Str, maxStr := "—", "—", "—", "—", "—"
|
||||
if v.rolling.n > 0 {
|
||||
okStr = fmt.Sprintf("%.1f", v.rolling.successPct())
|
||||
p50ns, p95ns, p99ns := v.rolling.percentiles()
|
||||
p50Str = formatDur(time.Duration(p50ns))
|
||||
p95Str = formatDur(time.Duration(p95ns))
|
||||
p99Str = formatDur(time.Duration(p99ns))
|
||||
maxStr = formatDur(time.Duration(v.rolling.maxNS))
|
||||
}
|
||||
okStr = colourOK(okStr, v.rolling.successPct(), v.rolling.n)
|
||||
|
||||
return " " +
|
||||
padVisibleRight(scheme, colSchemeW) + " " +
|
||||
padVisibleRight(afLabel(v.info), colAFW) + " " +
|
||||
padVisibleRight(addr, colAddrW) + " " +
|
||||
padVisibleRight(last, colLastW) + " " +
|
||||
padVisibleLeft(nStyled, colNW) + " " +
|
||||
padVisibleLeft(failStyled, colFailW) + " " +
|
||||
padVisibleLeft(okStr, colOKW) + " " +
|
||||
padVisibleLeft(p50Str, colP50W) + " " +
|
||||
padVisibleLeft(p95Str, colP95W) + " " +
|
||||
padVisibleLeft(p99Str, colP99W) + " " +
|
||||
padVisibleLeft(maxStr, colMaxW)
|
||||
}
|
||||
|
||||
// afLabel returns the address-family tag for the AF column.
|
||||
// IPv6 is identified by To4() == nil, which is how net.IP
|
||||
// distinguishes a 4-in-6 mapped address from a native v6 one.
|
||||
func afLabel(v *vipInfo) string {
|
||||
if v.ip.To4() == nil {
|
||||
return "v6"
|
||||
}
|
||||
return "v4"
|
||||
}
|
||||
|
||||
// displayAddr formats the address cell for a VIP, honouring the
|
||||
// Model.showDNS toggle. With DNS on (the default) and a PTR result
|
||||
// available we show "hostname:port"; otherwise we fall back to the
|
||||
// raw IP literal, bracketed for IPv6. Keeping the toggle in the
|
||||
// Model (rather than per-VIP) means pressing 'd' flips every row
|
||||
// on the next redraw, which is the behaviour the operator expects.
|
||||
func (m Model) displayAddr(v *vipState) string {
|
||||
if m.showDNS && v.hostname != "" {
|
||||
return fmt.Sprintf("%s:%d", v.hostname, v.info.port)
|
||||
}
|
||||
return vipAddrString(v.info)
|
||||
}
|
||||
|
||||
// schemeLabel renders the coloured scheme tag for the PROTO column.
|
||||
// The raw token ("http", "https", "tcp") is returned wrapped in an
|
||||
// ANSI style; callers are responsible for padding it to colSchemeW
|
||||
// via padVisibleRight when they want column alignment. Use
|
||||
// schemeAddrLabel when you want a pre-padded "PROTO ADDR" tuple
|
||||
// aligned with the main table.
|
||||
func schemeLabel(scheme string) string {
|
||||
switch scheme {
|
||||
case "http":
|
||||
return styleHTTP.Render("http")
|
||||
case "https":
|
||||
return styleHTTP.Render("https")
|
||||
default:
|
||||
return styleTCP.Render("tcp")
|
||||
}
|
||||
}
|
||||
|
||||
// schemeAddrLabel builds the shared "PROTO AF ADDR" label used by
|
||||
// the tally and events panels. Both sections need to line up under
|
||||
// the main probe table's PROTO + AF + ADDR columns, which means
|
||||
// every fixed-width cell must be padded before the next one is
|
||||
// appended — otherwise "http" + " " + addr and "https" + " " +
|
||||
// addr would start the address column one character apart. The
|
||||
// helper is the single source of truth so the two call sites can't
|
||||
// drift out of sync with the main table layout. ADDR honours the
|
||||
// Model.showDNS toggle via displayAddr so toggling 'd' flips every
|
||||
// section of the TUI in lockstep.
|
||||
func (m Model) schemeAddrLabel(v *vipState) string {
|
||||
return padVisibleRight(schemeLabel(v.info.scheme), colSchemeW) + " " +
|
||||
padVisibleRight(afLabel(v.info), colAFW) + " " +
|
||||
m.displayAddr(v)
|
||||
}
|
||||
|
||||
// vipAddrString formats an address+port for the ADDR column, with
|
||||
// IPv6 literals bracketed so the colons in the address don't blur
|
||||
// into the port separator visually. No scheme prefix — the PROTO
|
||||
// column handles that, which also frees up width for the address
|
||||
// itself.
|
||||
func vipAddrString(v *vipInfo) string {
|
||||
host := v.ip.String()
|
||||
if v.ip.To4() == nil {
|
||||
host = "[" + host + "]"
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", host, v.port)
|
||||
}
|
||||
|
||||
// lastCell returns the colour-rendered LAST column for a single VIP,
|
||||
// combining the most recent status code (or error token) with a
|
||||
// bold colour that encodes success vs warning vs failure. Idle VIPs
|
||||
// (no probe yet) render as a dim dash.
|
||||
func lastCell(v *vipState) string {
|
||||
if v.lastAt.IsZero() {
|
||||
return styleDim.Render("—")
|
||||
}
|
||||
if v.lastErr != "" {
|
||||
return styleErr.Render(v.lastErr)
|
||||
}
|
||||
if v.info.scheme == "tcp" {
|
||||
if v.lastOK {
|
||||
return styleOK.Render("ok")
|
||||
}
|
||||
return styleErr.Render("fail")
|
||||
}
|
||||
// HTTP / HTTPS: show the status code, coloured by class.
|
||||
txt := fmt.Sprintf("%d", v.lastCode)
|
||||
switch {
|
||||
case v.lastCode >= 200 && v.lastCode < 300:
|
||||
return styleOK.Render(txt)
|
||||
case v.lastCode >= 300 && v.lastCode < 400:
|
||||
return styleWarn.Render(txt)
|
||||
case v.lastCode >= 400 && v.lastCode < 500:
|
||||
return styleWarn.Render(txt)
|
||||
default:
|
||||
return styleErr.Render(txt)
|
||||
}
|
||||
}
|
||||
|
||||
// colourOK renders the OK% cell with a threshold-based colour. The
|
||||
// input string is the pre-formatted percentage text (or "-" if the
|
||||
// window is empty); the decision is made on the raw float so "99.9"
|
||||
// and "99.0" don't both end up the same colour by round-up. The
|
||||
// window-size check avoids painting a green "100.0" after a single
|
||||
// successful probe — we wait until the rolling window is at least
|
||||
// 10 samples deep before committing to a verdict.
|
||||
func colourOK(txt string, pct float64, n int) string {
|
||||
if n < 10 {
|
||||
return styleDim.Render(txt)
|
||||
}
|
||||
switch {
|
||||
case pct >= 99:
|
||||
return styleOK.Render(txt)
|
||||
case pct >= 95:
|
||||
return styleWarn.Render(txt)
|
||||
default:
|
||||
return styleErr.Render(txt)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) anyTallied() bool {
|
||||
for _, v := range m.vips {
|
||||
if len(v.tally) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Model) viewTally() string {
|
||||
// Label each tally row with the same PROTO + ADDR pair the
|
||||
// main table uses, so the operator can correlate a row in the
|
||||
// tally back to the probe-table row without a symbolic VIP
|
||||
// name. Label width is the combined width of the two columns
|
||||
// plus their inter-column gap so things line up.
|
||||
const labelW = colSchemeW + 2 + colAFW + 2 + colAddrW
|
||||
|
||||
// Pre-pass: compute the widest (name:count) entry across every
|
||||
// VIP's tally. Every rendered entry is padded to this width so
|
||||
// rows stay vertically aligned as counts grow — one backend's
|
||||
// count ticking from 999 to 1000 widens every entry by one
|
||||
// character simultaneously, rather than just shifting that
|
||||
// single row's trailing entries rightward.
|
||||
maxEntryW := 0
|
||||
for _, v := range m.vips {
|
||||
if v.info.scheme == "tcp" {
|
||||
continue
|
||||
}
|
||||
for name, n := range v.tally {
|
||||
w := len(name) + 1 + countDigits(n) // "name" + ":" + digits
|
||||
if w > maxEntryW {
|
||||
maxEntryW = w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(styleSection.Render(fmt.Sprintf("%s tally:", m.opts.Header)))
|
||||
b.WriteString("\n")
|
||||
for _, v := range m.vips {
|
||||
if v.info.scheme == "tcp" || len(v.tally) == 0 {
|
||||
continue
|
||||
}
|
||||
b.WriteString(m.renderTallyRow(v, maxEntryW, labelW))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderTallyRow builds one tally line for a single VIP. Entries
|
||||
// are sorted alphabetically — stable across failovers, and stable
|
||||
// under the inevitable jitter where three "equally loaded" backends
|
||||
// shuffle their exact counts from probe to probe. A count-sorted
|
||||
// order looks informative on a static screenshot but flickers on a
|
||||
// live display, and the operator ends up reading the names anyway
|
||||
// to figure out which column is which. Alphabetical pins every
|
||||
// label to its own column for the lifetime of the process.
|
||||
//
|
||||
// Colour is binary: white if the backend was seen at least once in
|
||||
// the last tallyWindow, grey otherwise. Green is deliberately
|
||||
// avoided here — it means "success" elsewhere in the TUI (OK%, 2xx
|
||||
// status codes) and carries a value judgement the tally doesn't
|
||||
// intend to make. An active tally entry is just "in the rotation
|
||||
// right now", not "good". The earlier three-way (green/orange/grey)
|
||||
// scheme tried to distinguish "row leader" from "still receiving
|
||||
// some traffic", but on a healthy VIP where maglev spreads flows
|
||||
// evenly, the three backends tie ±a few counts per window and
|
||||
// flicker between colours on every redraw — visual noise, not
|
||||
// signal.
|
||||
//
|
||||
// During the first tallyWindow after startup or a reset, v.tallyOld
|
||||
// is the empty map, so every positive count reads as active and the
|
||||
// row flashes all-white. That's correct: we haven't observed a
|
||||
// drain yet, so nothing is drained.
|
||||
func (m Model) renderTallyRow(v *vipState, maxEntryW, labelW int) string {
|
||||
keys := make([]string, 0, len(v.tally))
|
||||
for k := range v.tally {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
n := v.tally[k]
|
||||
raw := fmt.Sprintf("%s:%d", k, n)
|
||||
// delta < 0 is the transient post-reset case where the
|
||||
// tally dropped below the old snapshot; treat that as
|
||||
// "no activity" until the snapshot rotates on the next
|
||||
// tick.
|
||||
active := n-v.tallyOld[k] > 0
|
||||
var styled string
|
||||
if active {
|
||||
// White (not green): green reads as "success" in the
|
||||
// rest of the TUI — reserving it for OK% and 2xx
|
||||
// statuses keeps the semantic clean. White is just
|
||||
// "this backend is live right now" without the value
|
||||
// judgement.
|
||||
styled = styleWhite.Render(raw)
|
||||
} else {
|
||||
styled = styleDim.Render(raw)
|
||||
}
|
||||
parts = append(parts, padVisibleRight(styled, maxEntryW))
|
||||
}
|
||||
label := m.schemeAddrLabel(v)
|
||||
return " " + padVisibleRight(truncateVisible(label, labelW), labelW) +
|
||||
" " + strings.Join(parts, " ") + "\n"
|
||||
}
|
||||
|
||||
// countDigits returns the number of decimal digits in n. Faster and
|
||||
// allocation-free vs fmt.Sprintf("%d", n) and good enough for the
|
||||
// positive-integer tally counts (we never feed it negatives).
|
||||
func countDigits(n int) int {
|
||||
if n == 0 {
|
||||
return 1
|
||||
}
|
||||
d := 0
|
||||
for n > 0 {
|
||||
n /= 10
|
||||
d++
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// viewEvents renders the error panel into a fixed number of rows
|
||||
// (maxRows, computed per-frame from the terminal height by View()).
|
||||
// Only the most recent `maxRows` events are shown — anything older
|
||||
// scrolls off. When the panel clips, the header notes "showing N
|
||||
// of M" so the operator knows there's older history they can't see
|
||||
// without resetting and starting fresh.
|
||||
//
|
||||
// maxRows <= 0 or an empty events ring means "don't render this
|
||||
// section at all"; View() skips the whole block in that case.
|
||||
//
|
||||
// Each row carries a millisecond-precision timestamp, the VIP's
|
||||
// PROTO+ADDR label (same columns as the main table so the eye can
|
||||
// jump between the two), the event kind in a fixed 9-char slot, and
|
||||
// a free-form detail string. Network errors render red, HTTP errors
|
||||
// render red, spikes render yellow to distinguish "backend responded
|
||||
// but slowly" from "backend didn't respond at all".
|
||||
func (m Model) viewEvents(maxRows int) string {
|
||||
if maxRows < 0 {
|
||||
return ""
|
||||
}
|
||||
const (
|
||||
labelW = colSchemeW + 2 + colAFW + 2 + colAddrW
|
||||
kindW = 9
|
||||
tsW = 12 // "HH:MM:SS.mmm"
|
||||
)
|
||||
var b strings.Builder
|
||||
// Header slot 1: no events yet. The section header still
|
||||
// renders so the operator always sees where the panel lives;
|
||||
// a (none) tag makes the empty state obvious rather than
|
||||
// leaving the operator wondering whether the panel is broken.
|
||||
if len(m.events) == 0 {
|
||||
fmt.Fprintf(&b, "%s %s\n",
|
||||
styleSection.Render("Recent events:"),
|
||||
styleDim.Render("(none)"))
|
||||
return b.String()
|
||||
}
|
||||
// maxRows is the budget for event *rows*, not including the
|
||||
// section-header line itself — View() reserved one extra row
|
||||
// for the header when it computed the budget, so we can spend
|
||||
// the full maxRows on events below.
|
||||
events := m.events
|
||||
clipped := false
|
||||
if len(events) > maxRows {
|
||||
events = events[len(events)-maxRows:]
|
||||
clipped = true
|
||||
}
|
||||
if clipped {
|
||||
fmt.Fprintf(&b, "%s\n", styleSection.Render(
|
||||
fmt.Sprintf("Recent events (showing %d of %d):", len(events), len(m.events))))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%s\n", styleSection.Render("Recent events:"))
|
||||
}
|
||||
for _, e := range events {
|
||||
v := m.vips[e.VIPIdx]
|
||||
label := m.schemeAddrLabel(v)
|
||||
ts := styleDim.Render(e.At.Format("15:04:05.000"))
|
||||
kindTxt := e.Kind.String()
|
||||
var kind string
|
||||
switch e.Kind {
|
||||
case kindSpike:
|
||||
kind = styleWarn.Render(padVisibleRight(kindTxt, kindW))
|
||||
default:
|
||||
kind = styleErr.Render(padVisibleRight(kindTxt, kindW))
|
||||
}
|
||||
fmt.Fprintf(&b, " %s %s %s %s\n",
|
||||
padVisibleRight(ts, tsW),
|
||||
padVisibleRight(truncateVisible(label, labelW), labelW),
|
||||
kind,
|
||||
e.Detail,
|
||||
)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) viewFooter() string {
|
||||
dnsHint := "[d] dns off"
|
||||
if !m.showDNS {
|
||||
dnsHint = "[d] dns on"
|
||||
}
|
||||
hints := []string{
|
||||
"[q] quit",
|
||||
"[space] pause/resume",
|
||||
"[r] reset",
|
||||
dnsHint,
|
||||
"[h] help",
|
||||
}
|
||||
return styleHint.Render(strings.Join(hints, " "))
|
||||
}
|
||||
|
||||
func (m Model) viewHelp() string {
|
||||
lines := []string{
|
||||
styleHeader.Render("maglevt — keybindings"),
|
||||
"",
|
||||
" q / ctrl-c quit",
|
||||
" space pause / resume all probe loops",
|
||||
" r reset rolling stats + tally + uptime",
|
||||
" d toggle hostname / IP-literal ADDR display",
|
||||
" h / ? toggle this help overlay",
|
||||
"",
|
||||
styleSection.Render("columns"),
|
||||
"",
|
||||
" PROTO http / https (port 80/443) or tcp (everything else)",
|
||||
" AF address family — v4 or v6",
|
||||
" ADDR VIP address + port, or reverse-DNS hostname (toggle 'd')",
|
||||
" LAST most-recent probe result, coloured by class",
|
||||
" N lifetime probe count since startup (or last 'r')",
|
||||
" FAIL lifetime failure count; red when non-zero",
|
||||
" OK% success ratio over the last 100 samples",
|
||||
" p50/p95/p99 latency percentiles over the last 100 samples",
|
||||
" max worst-case latency over the last 100 samples",
|
||||
"",
|
||||
styleSection.Render("tally"),
|
||||
"",
|
||||
" Running count of the response header configured via --header",
|
||||
" (default X-IPng-Frontend), grouped by PROTO + ADDR. Entries",
|
||||
" are sorted alphabetically so each backend owns its column",
|
||||
" for the lifetime of the process. Colour marks recent",
|
||||
" activity over the last ~5s window:",
|
||||
" white received at least one hit in the window",
|
||||
" grey idle — flushed or fully drained",
|
||||
" Reset with 'r'.",
|
||||
"",
|
||||
styleSection.Render("events panel"),
|
||||
"",
|
||||
" Rolling list of probes that warrant attention. The panel auto-",
|
||||
" sizes to fill whatever vertical space is left between the tally",
|
||||
" and the footer, so a taller terminal shows more history. Older",
|
||||
" events are still stored (up to 500) and re-appear if the",
|
||||
" terminal is enlarged; the header notes \"showing N of M\" when",
|
||||
" the display is clipping. Four event kinds:",
|
||||
" timeout probe hit its --timeout deadline",
|
||||
" http-err response carried a 4xx or 5xx status code",
|
||||
" net-err TCP refused, reset, unreachable, or TLS error",
|
||||
" spike successful probe more than 25% above the rolling",
|
||||
" window max (warmup: at least 10 samples required)",
|
||||
" Cleared along with everything else by 'r'.",
|
||||
"",
|
||||
styleHint.Render("Press h or ? again to dismiss."),
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// formatDur renders a time.Duration in a compact form for the
|
||||
// p50/p95 columns: sub-millisecond shows in µs, sub-second in ms,
|
||||
// and anything longer in seconds with a decimal place. Fits within
|
||||
// the 9-char column cleanly.
|
||||
func formatDur(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "-"
|
||||
}
|
||||
switch {
|
||||
case d < time.Millisecond:
|
||||
return fmt.Sprintf("%dµs", d.Microseconds())
|
||||
case d < time.Second:
|
||||
return fmt.Sprintf("%.1fms", float64(d.Microseconds())/1000)
|
||||
default:
|
||||
return fmt.Sprintf("%.2fs", d.Seconds())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user