Files
cabletest/ui.go
T

584 lines
15 KiB
Go

package main
import (
"fmt"
"math"
"runtime/debug"
"strconv"
"strings"
"time"
)
// Panel counts fit four characters forever: whole numbers to 9999, then a
// magnitude letter with at most one decimal. The value columns are sized to
// this bound, so no count can ever grow into the timeline.
func scalePanelCount(v uint64) string {
if v < 10000 {
return strconv.FormatUint(v, 10)
}
f := float64(v)
for _, mag := range []string{"k", "M", "G", "T", "P"} {
f /= 1000
if f < 9.95 {
return fmt.Sprintf("%.1f%s", f, mag)
}
if f < 999.5 {
return fmt.Sprintf("%.0f%s", f, mag)
}
}
return fmt.Sprintf("%.0fE", f/1000)
}
var (
uiBg = rgb{0x0d, 0x0d, 0x0d}
uiInk = rgb{0xf2, 0xf2, 0xf0}
uiInk2 = rgb{0xc3, 0xc2, 0xb7}
uiMuted = rgb{0x89, 0x87, 0x81}
uiHair = rgb{0x28, 0x28, 0x26}
uiGood = rgb{0x0c, 0xa3, 0x0c}
uiWarn = rgb{0xfa, 0xb2, 0x19}
uiCrit = rgb{0xd0, 0x3b, 0x3b}
uiCellOK = rgb{0x0a, 0x5d, 0x0a}
uiCellNA = rgb{0x3a, 0x3a, 0x37}
uiAccent = rgb{0x39, 0x87, 0xe5}
)
func classColor(c int) rgb {
switch c {
case clsGood:
return uiGood
case clsWarn:
return uiWarn
case clsBad:
return uiCrit
}
return uiMuted
}
const (
uiMargin = 24
uiColGap = 12
headerTop = 26
ringD = 88
ringEdge = 5
// Minimum row and cell heights; layout stretches both proportionally to
// fill the height between the header and the footer.
cellHMin = 20
cellRMin = 4
cellGapX = 3
rowHMin = 46
btnW = 220
btnH = 72
btnRadius = 10
btnBorder = 2
holdDuration = time.Second
spinGap = math.Pi / 6
spinPunch = 9
spinPeriod = 2 * time.Second
versionSpotSide = 200
)
// The panel is the heat matrix: one row per check, columns for now, the last
// 90 seconds at 5 s a cell, and since reset.
var panelRows = []string{
"line rate", "lost", "corrupt", "link", "internal", "corrected", "SNR dB",
}
type rect struct {
x, y, w, h int
}
func (r rect) contains(x, y int) bool {
return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h
}
type display struct {
fb *framebuffer
huge *textFace
big *textFace
text *textFace
textB *textFace
small *textFace
headerH int
rowYs []int
rowH int
cellH int
cellR int
labelX int
nowX int
nowW int
cellsX int
cellW int
cellsW int
resetX int
resetW int
resetBtn rect
holdStart time.Time
holdFrac float64
fired bool
version string
versionSpot rect
showVersion bool
}
func newDisplay() (*display, error) {
fb, err := openFramebuffer()
if err != nil {
return nil, err
}
d := &display{fb: fb, version: buildVersion()}
for _, spec := range []struct {
dst **textFace
bold bool
size float64
}{
{&d.huge, true, 40},
{&d.big, true, 36},
{&d.text, false, 22},
{&d.textB, true, 22},
{&d.small, false, 17},
} {
face, err := loadFace(spec.bold, spec.size)
if err != nil {
fb.close()
return nil, err
}
*spec.dst = face
}
if err := d.layout(fb.w, fb.h); err != nil {
fb.close()
return nil, err
}
return d, nil
}
func (d *display) layout(w, h int) error {
labelW := 0
for _, r := range panelRows {
labelW = max(labelW, len(r)*d.text.cellW)
}
// Horizons narrow left to right — since reset, the last 90 s, this
// instant — so the NOW column sits where the timeline's own "now" lands.
d.labelX = uiMargin
d.nowW = 5 * d.textB.cellW
d.resetW = 5 * d.textB.cellW
d.resetX = d.labelX + labelW + uiColGap
d.nowX = w - uiMargin - d.nowW
cellsX := d.resetX + d.resetW + uiColGap
cellsW := d.nowX - uiColGap - cellsX
d.cellW = (cellsW - (histSlots-1)*cellGapX) / histSlots
if d.cellW < 6 {
return fmt.Errorf("heat cells would be %dpx wide", d.cellW)
}
d.cellsW = histSlots*d.cellW + (histSlots-1)*cellGapX
// The leftover from rounding sits between the cells and the now column.
d.cellsX = cellsX
d.headerH = headerTop + ringD + 24
colHdrH := d.small.lineH + 18
d.resetBtn = rect{w - uiMargin - btnW, h - uiMargin - btnH, btnW, btnH}
// Rows stretch to spend the whole band between the column headers and the
// footer, cells riding along at their design proportion of the row.
rowsTop := d.headerH + colHdrH
d.rowH = (d.resetBtn.y - uiMargin - rowsTop) / len(panelRows)
if d.rowH < rowHMin {
return fmt.Errorf("rows would be %dpx tall, under the %dpx minimum", d.rowH, rowHMin)
}
d.cellH = d.rowH * cellHMin / rowHMin
d.cellR = max(cellRMin, d.cellH*cellRMin/cellHMin)
d.rowYs = make([]int, len(panelRows))
y := rowsTop
for i := range panelRows {
d.rowYs[i] = y
y += d.rowH
}
d.versionSpot = rect{w - versionSpotSide, 0, versionSpotSide, versionSpotSide}
return nil
}
// Lifting or sliding off cancels, and the press has to be released before it
// can arm again.
func (d *display) holdReset(x, y int, down bool, now time.Time) bool {
if !down {
d.holdStart, d.holdFrac, d.fired = time.Time{}, 0, false
return false
}
if d.fired || !d.resetBtn.contains(x, y) {
d.holdStart, d.holdFrac = time.Time{}, 0
return false
}
if d.holdStart.IsZero() {
d.holdStart = now
}
d.holdFrac = now.Sub(d.holdStart).Seconds() / holdDuration.Seconds()
if d.holdFrac < 1 {
return false
}
d.holdFrac, d.fired, d.holdStart = 0, true, time.Time{}
return true
}
// Accent rather than the status colours because it is something to press, not
// something being reported.
func (d *display) drawResetButton() {
r := d.resetBtn
d.fb.roundRect(r.x, r.y, r.w, r.h, btnRadius, uiAccent)
d.fb.roundRect(r.x+btnBorder, r.y+btnBorder, r.w-2*btnBorder, r.h-2*btnBorder,
btnRadius-btnBorder, uiBg)
split := r.x + btnBorder
if d.holdFrac > 0 {
w := int(float64(r.w-2*btnBorder) * math.Min(d.holdFrac, 1))
d.fb.roundRect(r.x+btnBorder, r.y+btnBorder, w, r.h-2*btnBorder,
btnRadius-btnBorder, uiAccent)
split += w
}
label, base := "RESET", uiAccent
if d.showVersion {
label, base = d.version, uiMuted
}
lx := r.x + (r.w-len(label)*d.textB.cellW)/2
ly := r.y + (r.h-d.textB.lineH)/2 - d.textB.capTop
// The label straddles the fill, so each glyph takes the colour that reads
// against whatever is behind it.
for i, c := range label {
gx := lx + i*d.textB.cellW
col := base
if gx+d.textB.cellW/2 < split {
col = uiBg
}
d.textB.draw(d.fb, gx, ly, string(c), col)
}
}
// go run never stamps VCS info, so dev builds have no revision to show.
func buildVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "dev"
}
var rev string
var dirty bool
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
rev = s.Value
case "vcs.modified":
dirty = s.Value == "true"
}
}
if rev == "" {
return "dev"
}
if len(rev) > 9 {
rev = rev[:9]
}
if dirty {
rev += "+"
}
return rev
}
func (d *display) close() {
d.fb.close()
}
type seg struct {
f *textFace
s string
c rgb
}
func (d *display) segs(x, y int, ss []seg) int {
for _, g := range ss {
g.f.draw(d.fb, x, y-g.f.capTop, g.s, g.c)
x += len([]rune(g.s)) * g.f.cellW
}
return x
}
func (d *display) textRightAt(f *textFace, right, y int, s string, c rgb) {
f.draw(d.fb, right-len([]rune(s))*f.cellW, y-f.capTop, s, c)
}
// The font has no check or cross, so both are stroked to match its weight.
func (d *display) check(cx, cy int, s float64, c rgb) {
t := math.Max(2.4, s*0.16)
fx, fy := float64(cx), float64(cy)
d.fb.stroke(fx-0.38*s, fy+0.04*s, fx-0.12*s, fy+0.30*s, t, c)
d.fb.stroke(fx-0.12*s, fy+0.30*s, fx+0.40*s, fy-0.28*s, t, c)
}
func (d *display) cross(cx, cy int, s float64, c rgb) {
t := math.Max(2.4, s*0.16)
fx, fy := float64(cx), float64(cy)
d.fb.stroke(fx-0.30*s, fy-0.30*s, fx+0.30*s, fy+0.30*s, t, c)
d.fb.stroke(fx-0.30*s, fy+0.30*s, fx+0.30*s, fy-0.30*s, t, c)
}
func (d *display) hairline(y int) {
d.fb.rect(uiMargin, y, d.fb.w-2*uiMargin, 1, uiHair)
}
// A mark or short value in the NOW/RESET columns, right-aligned to the
// column's edge and vertically centred in the row.
func (d *display) colMark(right, rowY int, kind int, c rgb) {
cy := rowY + d.rowH/2
switch kind {
case markCheck:
d.check(right-11, cy, 20, c)
case markCross:
d.cross(right-11, cy, 20, c)
}
}
const (
markCheck = iota
markCross
)
func (d *display) colText(f *textFace, right, rowY int, s string, c rgb) {
d.textRightAt(f, right, rowY+(d.rowH-f.lineH)/2, s, c)
}
func (d *display) heatCell(rowY, k int, c rgb) {
x := d.cellsX + k*(d.cellW+cellGapX)
d.fb.roundRect(x, rowY+(d.rowH-d.cellH)/2, d.cellW, d.cellH, d.cellR, c)
}
// The verdict names the newest thing wrong at any horizon — status only, the
// counts live in the rows: the cable's own fault first, then the most recent
// traffic fault with its age, and only a clean record reads GOOD. While a
// measure runs the tester is asserting nothing, and says so instead of GOOD.
func (d *display) drawHeader(phy phyDisplay, hv histView, measuring bool,
now time.Time) {
good := true
word, sub := "GOOD", []seg{{d.text, "no faults", uiInk2}}
if i, age, ok := hv.newestFault(now); ok {
good = false
word = strings.ToUpper(faultClasses[i].label)
sub = []seg{
{d.text, "last " + faultClasses[i].noun + " ", uiInk2},
{d.textB, scaleTime(age) + " ago", uiInk},
}
}
if phy.metresClass == clsBad {
good = false
word = phy.metres
sub = []seg{{d.text, "cable fault", uiInk2}}
}
col := uiGood
if !good {
col = uiCrit
}
if measuring {
word, sub, col = "MEASURING", []seg{{d.text, "cable diag running", uiInk2}}, uiMuted
}
rx, ry := uiMargin, headerTop
d.fb.roundRect(rx, ry, ringD, ringD, ringD/2, col)
d.fb.roundRect(rx+ringEdge, ry+ringEdge, ringD-2*ringEdge, ringD-2*ringEdge,
ringD/2-ringEdge, uiBg)
spin := float64(now.UnixNano()%int64(spinPeriod)) / float64(spinPeriod)
cx, cy := float64(rx)+ringD/2, float64(ry)+ringD/2
cr := (ringD - ringEdge) / 2.0
a0, a1 := 2*math.Pi*spin-spinGap/2, 2*math.Pi*spin+spinGap/2
d.fb.stroke(cx+cr*math.Sin(a0), cy-cr*math.Cos(a0),
cx+cr*math.Sin(a1), cy-cr*math.Cos(a1), spinPunch, uiBg)
switch {
case measuring:
case good:
d.check(rx+ringD/2, ry+ringD/2, 44, col)
default:
d.cross(rx+ringD/2, ry+ringD/2, 40, col)
}
tx := rx + ringD + 22
d.huge.draw(d.fb, tx, ry+8-d.huge.capTop, word, col)
d.segs(tx, ry+8+d.huge.lineH+16, sub)
// The cable's own cell, top right: the one per-measure fact.
unit := ""
if phy.metresClass == clsGood {
unit = " m"
}
right := d.fb.w - uiMargin
vw := len([]rune(phy.metres))*d.big.cellW + len([]rune(unit))*d.small.cellW
d.segs(right-vw, ry+8, []seg{
{d.big, phy.metres, classColor(phy.metresClass)},
{d.small, unit, uiMuted},
})
d.textRightAt(d.small, right, ry+8+d.big.lineH+14, "cable", uiMuted)
}
func (d *display) drawColHeaders() {
y := d.headerH + 8
d.textRightAt(d.small, d.resetX+d.resetW, y, "RESET", uiMuted)
hdr := "LAST 90 S"
hw := len([]rune(hdr)) * d.small.cellW
d.small.draw(d.fb, d.cellsX+(d.cellsW-hw)/2, y-d.small.capTop, hdr, uiMuted)
d.textRightAt(d.small, d.nowX+d.nowW, y, "NOW", uiMuted)
d.hairline(d.rowYs[0] - 1)
}
// One row of history cells: na for a slot with nothing believable (never
// sampled, or spent inside a measure), otherwise judged by the row.
func (d *display) heatRow(rowY int, hv histView, judge func(histSlot) rgb) {
for k, s := range hv.slots {
c := uiCellNA
if s.sampled && !s.measuring {
c = judge(s)
}
d.heatCell(rowY, k, c)
}
}
func okOr(bad bool, c rgb) rgb {
if bad {
return c
}
return uiCellOK
}
func (d *display) drawRows(v view, phy phyDisplay, measuring bool,
hv histView, target float64) {
nowR := d.nowX + d.nowW
resetR := d.resetX + d.resetW
for i, label := range panelRows {
y := d.rowYs[i]
d.text.draw(d.fb, d.labelX, y+(d.rowH-d.text.lineH)/2-d.text.capTop, label, uiInk2)
d.hairline(y + d.rowH - 1)
switch label {
case "line rate":
if measuring {
d.colText(d.text, nowR, y, "-", uiMuted)
} else if v.rxGbps >= rateOKFrac*target {
d.colMark(nowR, y, markCheck, uiGood)
} else {
d.colMark(nowR, y, markCross, uiCrit)
}
d.heatRow(y, hv, func(s histSlot) rgb { return okOr(s.rateLow, uiCrit) })
if hv.sinceRateLow {
d.colMark(resetR, y, markCross, uiCrit)
} else {
d.colMark(resetR, y, markCheck, uiGood)
}
case "corrected":
if phy.recent > 0 {
d.colText(d.textB, nowR, y, scalePanelCount(phy.recent), uiWarn)
} else {
d.colMark(nowR, y, markCheck, uiGood)
}
d.heatRow(y, hv, func(s histSlot) rgb { return okOr(s.corrected > 0, uiWarn) })
if phy.corrected > 0 {
d.colText(d.textB, resetR, y, scalePanelCount(phy.corrected), uiWarn)
} else {
d.colText(d.text, resetR, y, "0", uiMuted)
}
case "SNR dB":
if phy.haveSNR {
d.colText(d.textB, nowR, y, fmt.Sprintf("%+.1f", phy.worstMargin),
snrInk(phy.worstMargin))
} else {
d.colText(d.text, nowR, y, "-", uiMuted)
}
d.heatRow(y, hv, func(s histSlot) rgb {
if !s.haveSNR {
return uiCellNA
}
return okOr(snrClass(s.snrMin) != clsGood, classColor(snrClass(s.snrMin)))
})
if hv.sinceHaveSNR {
d.colText(d.textB, resetR, y, fmt.Sprintf("%+.1f", hv.sinceSNRMin),
snrInk(hv.sinceSNRMin))
} else {
d.colText(d.text, resetR, y, "-", uiMuted)
}
default:
f := -1
for j, c := range faultClasses {
if c.label == label {
f = j
break
}
}
if f < 0 {
panic("panel row " + label + " has no fault class")
}
if n := faultClasses[f].get(v.window); n > 0 {
d.colText(d.textB, nowR, y, scalePanelCount(n), uiCrit)
} else {
d.colMark(nowR, y, markCheck, uiGood)
}
d.heatRow(y, hv, func(s histSlot) rgb { return okOr(s.faults[f] > 0, uiCrit) })
if n := faultClasses[f].get(v.since); n > 0 {
d.colText(d.textB, resetR, y, scalePanelCount(n), uiCrit)
} else {
d.colText(d.text, resetR, y, "0", uiMuted)
}
}
}
}
// SNR is a number wherever it appears, tinted only when it is worth worrying
// about.
func snrInk(m float64) rgb {
if c := snrClass(m); c != clsGood {
return classColor(c)
}
return uiInk
}
// The magnitude letter belongs to the unit, not the figure — split G from B
// and "GB" reads as two things — so it takes the unit's colour.
func (d *display) qty(v uint64, unit string) []seg {
val, mag, ok := strings.Cut(scaleCount(v), " ")
if ok {
unit = " " + mag + unit
}
return []seg{{d.small, val, uiInk2}, {d.small, unit, uiMuted}}
}
// One fact per line, stacked beside the reset button: short lines can never
// crowd it, whatever the values grow to.
func (d *display) drawFooter(v view, elapsed time.Duration) {
const gap = 6
lines := [][]seg{
{{d.small, scaleTime(elapsed), uiInk2}},
d.qty(v.rxFrames, " pkts"),
d.qty(v.rxBytes, "B"),
}
total := len(lines)*d.small.lineH + (len(lines)-1)*gap
y := d.resetBtn.y + (btnH-total)/2
for _, l := range lines {
d.segs(uiMargin, y, l)
y += d.small.lineH + gap
}
}
func (d *display) render(v view, elapsed time.Duration, phy phyDisplay,
measuring bool, hv histView, target float64) error {
fb := d.fb
fb.fill(uiBg)
d.drawHeader(phy, hv, measuring, time.Now())
d.hairline(d.headerH - 1)
d.drawColHeaders()
d.drawRows(v, phy, measuring, hv, target)
d.drawFooter(v, elapsed)
d.drawResetButton()
return fb.flush()
}