Replace the cfgPath field in the TUI header with the system's fully-qualified hostname via gethostname + CNAME lookup, matching what `hostname -f` produces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
725 lines
26 KiB
Go
725 lines
26 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
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.host,
|
|
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())
|
|
}
|
|
}
|