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:
2026-04-15 01:23:34 +02:00
parent 744b1cb3d2
commit 6293521157
8 changed files with 1890 additions and 1 deletions

724
cmd/tester/view.go Normal file
View 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())
}
}