2026-07-25 18:13:58 -07:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2026-08-04 11:27:35 -07:00
|
|
|
"strconv"
|
2026-07-25 18:13:58 -07:00
|
|
|
"strings"
|
2026-07-25 18:33:36 -07:00
|
|
|
"time"
|
2026-07-25 18:13:58 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
cReset = "\x1b[0m"
|
|
|
|
|
cBold = "\x1b[1m"
|
|
|
|
|
cDim = "\x1b[2m"
|
|
|
|
|
cRed = "\x1b[31m"
|
|
|
|
|
cGreen = "\x1b[32m"
|
|
|
|
|
cYellow = "\x1b[33m"
|
|
|
|
|
cCyan = "\x1b[36m"
|
|
|
|
|
cGrey = "\x1b[90m"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func paint(s, code string) string {
|
2026-07-25 18:20:34 -07:00
|
|
|
if s == "" {
|
2026-07-25 18:13:58 -07:00
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
return code + s + cReset
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func stripANSI(s string) string {
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
for i := 0; i < len(s); {
|
|
|
|
|
if s[i] == 0x1b {
|
|
|
|
|
for i < len(s) && s[i] != 'm' {
|
|
|
|
|
i++
|
|
|
|
|
}
|
|
|
|
|
if i < len(s) {
|
|
|
|
|
i++
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
b.WriteByte(s[i])
|
|
|
|
|
i++
|
|
|
|
|
}
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func visWidth(s string) int {
|
|
|
|
|
return len([]rune(stripANSI(s)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func pad(s string, w int, right bool) string {
|
|
|
|
|
gap := w - visWidth(s)
|
|
|
|
|
if gap < 0 {
|
|
|
|
|
gap = 0
|
|
|
|
|
}
|
|
|
|
|
if right {
|
|
|
|
|
return strings.Repeat(" ", gap) + s
|
|
|
|
|
}
|
|
|
|
|
return s + strings.Repeat(" ", gap)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func commas(v uint64) string {
|
|
|
|
|
s := fmt.Sprintf("%d", v)
|
|
|
|
|
if len(s) <= 3 {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
var parts []string
|
|
|
|
|
for len(s) > 3 {
|
|
|
|
|
parts = append([]string{s[len(s)-3:]}, parts...)
|
|
|
|
|
s = s[:len(s)-3]
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(append([]string{s}, parts...), ",")
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// The magnitude letter goes with the figure so the unit can stay a fixed word
|
|
|
|
|
// on the label. Below a thousand no letter is left dangling, since a trailing
|
|
|
|
|
// space would push the figure off centre.
|
2026-08-01 16:07:44 -07:00
|
|
|
func scaleSI(v float64) string {
|
2026-08-04 22:26:07 -07:00
|
|
|
// %.2f rounds 999.995 and up to a fourth digit, so the magnitude rolls over
|
|
|
|
|
// where the rounding does rather than at the bare thousand.
|
2026-08-01 16:07:44 -07:00
|
|
|
for _, mag := range []string{"", "k", "M", "G", "T"} {
|
2026-08-04 22:26:07 -07:00
|
|
|
if v < 999.995 {
|
2026-08-01 16:07:44 -07:00
|
|
|
if mag == "" {
|
|
|
|
|
return fmt.Sprintf("%.2f", v)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%.2f %s", v, mag)
|
|
|
|
|
}
|
|
|
|
|
v /= 1000
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%.2f P", v)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 11:27:35 -07:00
|
|
|
// The integer counterpart, for whole things counted rather than a rate
|
|
|
|
|
// measured. Below a thousand the figure is the count itself, since two decimals
|
|
|
|
|
// on a quantity that cannot have them read as precision that is not there.
|
|
|
|
|
func scaleCount(v uint64) string {
|
|
|
|
|
if v < 1000 {
|
|
|
|
|
return strconv.FormatUint(v, 10)
|
|
|
|
|
}
|
|
|
|
|
return scaleSI(float64(v))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// The same shape for time, whose magnitudes are sixties and twenty-fours.
|
2026-08-01 16:07:44 -07:00
|
|
|
func scaleTime(d time.Duration) string {
|
2026-08-14 23:47:45 -07:00
|
|
|
s := int(d.Seconds())
|
2026-08-01 16:07:44 -07:00
|
|
|
switch {
|
|
|
|
|
case d < time.Minute:
|
2026-08-14 23:47:45 -07:00
|
|
|
return fmt.Sprintf("%ds", s)
|
2026-08-01 16:07:44 -07:00
|
|
|
case d < time.Hour:
|
2026-08-14 23:47:45 -07:00
|
|
|
return fmt.Sprintf("%dm%02ds", s/60, s%60)
|
2026-08-01 16:07:44 -07:00
|
|
|
case d < 24*time.Hour:
|
2026-08-14 23:47:45 -07:00
|
|
|
return fmt.Sprintf("%dh%02dm", s/3600, (s/60)%60)
|
2026-08-01 16:07:44 -07:00
|
|
|
}
|
2026-08-14 23:47:45 -07:00
|
|
|
return fmt.Sprintf("%dd%02dh", s/86400, (s/3600)%24)
|
2026-08-01 16:07:44 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:13:58 -07:00
|
|
|
type colSpec struct {
|
2026-08-04 14:02:53 -07:00
|
|
|
group string
|
2026-07-25 18:13:58 -07:00
|
|
|
title string
|
|
|
|
|
width int
|
|
|
|
|
right bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type streamTable struct {
|
|
|
|
|
cols []colSpec
|
|
|
|
|
sinceHeader int
|
|
|
|
|
headerEvery int
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 14:02:53 -07:00
|
|
|
// A group change is drawn as a vertical break, so the two halves of the row
|
|
|
|
|
// read apart without a second header line naming them.
|
|
|
|
|
func (t *streamTable) join(cells []string, brk string) string {
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
for i, c := range cells {
|
|
|
|
|
if i > 0 {
|
|
|
|
|
if t.cols[i].group != t.cols[i-1].group {
|
|
|
|
|
b.WriteString(brk)
|
|
|
|
|
} else {
|
|
|
|
|
b.WriteByte(' ')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
b.WriteString(c)
|
|
|
|
|
}
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:13:58 -07:00
|
|
|
func (t *streamTable) headerLines() []string {
|
2026-08-04 14:02:53 -07:00
|
|
|
titles := make([]string, len(t.cols))
|
|
|
|
|
rules := make([]string, len(t.cols))
|
|
|
|
|
for i, c := range t.cols {
|
|
|
|
|
titles[i] = paint(pad(c.title, c.width, c.right), cBold)
|
|
|
|
|
rules[i] = strings.Repeat("─", c.width)
|
2026-07-25 18:13:58 -07:00
|
|
|
}
|
|
|
|
|
return []string{
|
2026-08-04 14:02:53 -07:00
|
|
|
t.join(titles, paint(" │ ", cGrey)),
|
|
|
|
|
paint(t.join(rules, "─┼─"), cGrey),
|
2026-07-25 18:13:58 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:31:03 -07:00
|
|
|
func (t *streamTable) width() int {
|
|
|
|
|
w := 0
|
|
|
|
|
for i, c := range t.cols {
|
|
|
|
|
w += c.width
|
|
|
|
|
if i > 0 {
|
|
|
|
|
w++
|
2026-08-04 14:02:53 -07:00
|
|
|
if t.cols[i].group != t.cols[i-1].group {
|
|
|
|
|
w += 2
|
|
|
|
|
}
|
2026-07-25 19:31:03 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return w
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A labelled horizontal rule spanning the table, for marking a break in the
|
|
|
|
|
// stream of rows.
|
|
|
|
|
func (t *streamTable) rule(label string) string {
|
|
|
|
|
const lead = 3
|
|
|
|
|
text := " " + label + " "
|
|
|
|
|
trail := t.width() - lead - visWidth(text)
|
|
|
|
|
if trail < 0 {
|
|
|
|
|
trail = 0
|
|
|
|
|
}
|
|
|
|
|
return paint(strings.Repeat("─", lead), cGrey) +
|
|
|
|
|
paint(text, cDim) +
|
|
|
|
|
paint(strings.Repeat("─", trail), cGrey)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 18:13:58 -07:00
|
|
|
func (t *streamTable) emit(cells []string) []string {
|
|
|
|
|
var out []string
|
|
|
|
|
if t.sinceHeader == 0 || (t.headerEvery > 0 && t.sinceHeader >= t.headerEvery) {
|
|
|
|
|
out = append(out, t.headerLines()...)
|
|
|
|
|
t.sinceHeader = 0
|
|
|
|
|
}
|
|
|
|
|
var padded []string
|
|
|
|
|
for i, c := range t.cols {
|
|
|
|
|
v := ""
|
|
|
|
|
if i < len(cells) {
|
|
|
|
|
v = cells[i]
|
|
|
|
|
}
|
|
|
|
|
padded = append(padded, pad(v, c.width, c.right))
|
|
|
|
|
}
|
|
|
|
|
t.sinceHeader++
|
2026-08-04 14:02:53 -07:00
|
|
|
return append(out, t.join(padded, paint(" │ ", cGrey)))
|
2026-07-25 18:13:58 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renderBox(title string, headers []string, rights []bool, rows [][]string) string {
|
|
|
|
|
n := len(headers)
|
|
|
|
|
widths := make([]int, n)
|
|
|
|
|
for i, h := range headers {
|
|
|
|
|
widths[i] = visWidth(h)
|
|
|
|
|
}
|
|
|
|
|
for _, r := range rows {
|
|
|
|
|
for i := 0; i < n && i < len(r); i++ {
|
|
|
|
|
if w := visWidth(r[i]); w > widths[i] {
|
|
|
|
|
widths[i] = w
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
line := func(l, m, r string) string {
|
|
|
|
|
var parts []string
|
|
|
|
|
for _, w := range widths {
|
|
|
|
|
parts = append(parts, strings.Repeat("─", w+2))
|
|
|
|
|
}
|
|
|
|
|
return paint(l+strings.Join(parts, m)+r, cGrey)
|
|
|
|
|
}
|
|
|
|
|
rowText := func(cells []string, bold bool) string {
|
|
|
|
|
var parts []string
|
|
|
|
|
for i := 0; i < n; i++ {
|
|
|
|
|
v := ""
|
|
|
|
|
if i < len(cells) {
|
|
|
|
|
v = cells[i]
|
|
|
|
|
}
|
|
|
|
|
if bold {
|
|
|
|
|
v = paint(v, cBold)
|
|
|
|
|
}
|
|
|
|
|
parts = append(parts, " "+pad(v, widths[i], rights[i])+" ")
|
|
|
|
|
}
|
|
|
|
|
bar := paint("│", cGrey)
|
|
|
|
|
return bar + strings.Join(parts, bar) + bar
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var b strings.Builder
|
2026-07-26 10:33:38 -07:00
|
|
|
writeln := func(s string) {
|
|
|
|
|
b.WriteString(s)
|
|
|
|
|
b.WriteByte('\n')
|
|
|
|
|
}
|
2026-07-25 18:13:58 -07:00
|
|
|
if title != "" {
|
2026-07-26 10:33:38 -07:00
|
|
|
writeln(paint(title, cBold+cCyan))
|
2026-07-25 18:13:58 -07:00
|
|
|
}
|
2026-07-26 10:33:38 -07:00
|
|
|
writeln(line("┌", "┬", "┐"))
|
|
|
|
|
writeln(rowText(headers, true))
|
|
|
|
|
writeln(line("├", "┼", "┤"))
|
2026-07-25 18:13:58 -07:00
|
|
|
for _, r := range rows {
|
2026-07-26 10:33:38 -07:00
|
|
|
writeln(rowText(r, false))
|
2026-07-25 18:13:58 -07:00
|
|
|
}
|
|
|
|
|
b.WriteString(line("└", "┴", "┘"))
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 14:02:53 -07:00
|
|
|
// Whether rather than how many, matching the panel's top chips: over a window
|
|
|
|
|
// this short a count changes faster than it can be read.
|
|
|
|
|
func flagCell(v uint64) string {
|
|
|
|
|
if v == 0 {
|
|
|
|
|
return paint("ok", cGreen)
|
|
|
|
|
}
|
|
|
|
|
return paint("ERR", cRed)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 16:14:12 -07:00
|
|
|
// Exact rather than scaled: scaled, one lost frame and a thousand both read as
|
|
|
|
|
// 1.00, separated only by a letter.
|
2026-07-25 18:13:58 -07:00
|
|
|
func statusCell(v uint64) string {
|
|
|
|
|
s := commas(v)
|
|
|
|
|
if v == 0 {
|
|
|
|
|
return paint(s, cGreen)
|
|
|
|
|
}
|
|
|
|
|
return paint(s, cRed)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 07:10:39 -07:00
|
|
|
func snrCell(phy phyDisplay) string {
|
|
|
|
|
if !phy.haveSNR {
|
|
|
|
|
return paint("-", cGrey)
|
|
|
|
|
}
|
|
|
|
|
s := fmt.Sprintf("%+.1f", phy.worstMargin)
|
|
|
|
|
switch snrClass(phy.worstMargin) {
|
|
|
|
|
case clsGood:
|
2026-08-14 23:47:45 -07:00
|
|
|
return s
|
2026-08-13 07:10:39 -07:00
|
|
|
case clsWarn:
|
|
|
|
|
return paint(s, cYellow)
|
|
|
|
|
default:
|
|
|
|
|
return paint(s, cRed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func correctedCell(v uint64) string {
|
|
|
|
|
if v == 0 {
|
2026-08-14 23:47:45 -07:00
|
|
|
return "0"
|
2026-08-13 07:10:39 -07:00
|
|
|
}
|
|
|
|
|
return paint(commas(v), cYellow)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func correctedFlag(v uint64) string {
|
|
|
|
|
if v == 0 {
|
|
|
|
|
return paint("ok", cGreen)
|
|
|
|
|
}
|
|
|
|
|
return paint("warn", cYellow)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 14:04:16 -07:00
|
|
|
// Green either way while the cable is present — the cycle's phase is state,
|
|
|
|
|
// not health. Red stays what it was: the cable missing.
|
|
|
|
|
func noiseCell(nv noiseView) string {
|
|
|
|
|
switch {
|
|
|
|
|
case nv.missing > 0:
|
|
|
|
|
return paint("ERR", cRed)
|
|
|
|
|
case nv.on:
|
|
|
|
|
return paint("on", cGreen)
|
|
|
|
|
}
|
|
|
|
|
return paint("off", cGreen)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 19:17:25 -07:00
|
|
|
// Per-interval rates jitter by a couple of percent at line rate, so green has
|
|
|
|
|
// to cover that. Yellow means a real shortfall, red means badly off.
|
|
|
|
|
const (
|
|
|
|
|
rateGreenFrac = 0.95
|
|
|
|
|
rateYellowFrac = 0.80
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-01 16:07:44 -07:00
|
|
|
func rateCell(bits float64, target float64) string {
|
|
|
|
|
s := scaleSI(bits)
|
2026-07-25 18:13:58 -07:00
|
|
|
switch {
|
2026-08-01 16:07:44 -07:00
|
|
|
case bits >= target*rateGreenFrac:
|
2026-07-25 18:13:58 -07:00
|
|
|
return paint(s, cGreen)
|
2026-08-01 16:07:44 -07:00
|
|
|
case bits >= target*rateYellowFrac:
|
2026-07-25 18:13:58 -07:00
|
|
|
return paint(s, cYellow)
|
|
|
|
|
default:
|
|
|
|
|
return paint(s, cRed)
|
|
|
|
|
}
|
|
|
|
|
}
|