Files
cabletest/ui.go
T

613 lines
16 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}
uiCellMeas = rgb{0x20, 0x4d, 0x83}
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 = 190
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
autoBtn rect
autoDown 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}
d.autoBtn = rect{d.resetBtn.x - uiColGap - btnW, d.resetBtn.y, 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
}
func (d *display) fillButton(r rect, frac float64, label string, frame, ink rgb) {
d.fb.roundRect(r.x, r.y, r.w, r.h, btnRadius, frame)
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 frac > 0 {
w := int(float64(r.w-2*btnBorder) * math.Min(frac, 1))
d.fb.roundRect(r.x+btnBorder, r.y+btnBorder, w, r.h-2*btnBorder,
btnRadius-btnBorder, frame)
split += w
}
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 := ink
if gx+d.textB.cellW/2 < split {
col = uiBg
}
d.textB.draw(d.fb, gx, ly, string(c), col)
}
}
// Accent rather than the status colours because it is something to press, not
// something being reported.
func (d *display) drawResetButton() {
label, ink := "RESET", uiAccent
if d.showVersion {
label, ink = d.version, uiMuted
}
d.fillButton(d.resetBtn, d.holdFrac, label, uiAccent, ink)
}
// Muted until switched on; the fill is the time already spent toward the next
// automatic measure.
func (d *display) drawAutoButton(on bool, frac float64) {
c := uiMuted
if on {
c = uiAccent
}
d.fillButton(d.autoBtn, frac, "AUTO", c, c)
}
func (d *display) tapAuto(x, y int, down bool) bool {
was := d.autoDown
d.autoDown = down
return down && !was && d.autoBtn.contains(x, y)
}
// 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 never sampled, the non-committal
// measure color for one spent inside a cable 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
switch {
case s.measuring:
c = uiCellMeas
case s.sampled:
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, autoOn bool, autoFrac 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.drawAutoButton(autoOn, autoFrac)
d.drawResetButton()
return fb.flush()
}