// Copyright (c) 2026, Pim van Pelt 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()) } }