551 lines
14 KiB
Go
551 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"runtime/debug"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
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}
|
|
uiNoise = rgb{0x1c, 0x5c, 0xab}
|
|
uiNoise0 = rgb{0x24, 0x24, 0x23}
|
|
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
|
|
|
|
cellH = 20
|
|
cellR = 4
|
|
cellGapX = 3
|
|
rowH = 46
|
|
|
|
btnW = 220
|
|
btnH = 72
|
|
btnRadius = 10
|
|
btnBorder = 2
|
|
holdDuration = 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. Noise rides at the bottom as
|
|
// context — state, not a verdict.
|
|
var panelRows = []string{
|
|
"line rate", "lost", "corrupt", "link", "internal", "corrected", "SNR dB", "noise",
|
|
}
|
|
|
|
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
|
|
axisY 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)
|
|
}
|
|
d.labelX = uiMargin
|
|
d.nowW = 5 * d.textB.cellW
|
|
d.resetW = 6 * d.textB.cellW
|
|
d.nowX = d.labelX + labelW + uiColGap
|
|
d.resetX = w - uiMargin - d.resetW
|
|
|
|
cellsX := d.nowX + d.nowW + uiColGap
|
|
cellsW := d.resetX - 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 reset column.
|
|
d.cellsX = cellsX
|
|
|
|
d.headerH = headerTop + ringD + 24
|
|
colHdrH := d.small.lineH + 18
|
|
d.rowYs = make([]int, len(panelRows))
|
|
y := d.headerH + colHdrH
|
|
for i := range panelRows {
|
|
d.rowYs[i] = y
|
|
y += rowH
|
|
}
|
|
d.axisY = y + 8
|
|
|
|
d.resetBtn = rect{w - uiMargin - btnW, h - uiMargin - btnH, btnW, btnH}
|
|
if d.axisY+d.small.lineH > d.resetBtn.y {
|
|
return fmt.Errorf("panel content is %dpx taller than the screen leaves",
|
|
d.axisY+d.small.lineH-d.resetBtn.y)
|
|
}
|
|
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 + 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+(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+(rowH-cellH)/2, d.cellW, cellH, cellR, c)
|
|
}
|
|
|
|
// The verdict names the newest thing wrong at any horizon: the cable's own
|
|
// fault first, then the most recent traffic fault with its age, and only a
|
|
// clean record reads GOOD.
|
|
func (d *display) drawHeader(v view, phy phyDisplay, hv histView, 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 = scaleCount(faultClasses[i].get(v.since)) + " " +
|
|
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
|
|
}
|
|
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)
|
|
if good {
|
|
d.check(rx+ringD/2, ry+ringD/2, 44, col)
|
|
} else {
|
|
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.nowX+d.nowW, y, "NOW", 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.resetX+d.resetW, y, "RESET", uiMuted)
|
|
d.hairline(d.rowYs[0] - 1)
|
|
}
|
|
|
|
func (d *display) drawAxis() {
|
|
d.small.draw(d.fb, d.cellsX, d.axisY-d.small.capTop, "90s ago", uiMuted)
|
|
d.textRightAt(d.small, d.cellsX+d.cellsW, d.axisY, "now", uiMuted)
|
|
}
|
|
|
|
// 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, nv noiseView,
|
|
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+(rowH-d.text.lineH)/2-d.text.capTop, label, uiInk2)
|
|
d.hairline(y + 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, scaleCount(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, scaleCount(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)
|
|
}
|
|
case "noise":
|
|
state := "off"
|
|
if nv.on {
|
|
state = "on"
|
|
}
|
|
if nv.missing > 0 {
|
|
d.colMark(nowR, y, markCross, uiCrit)
|
|
} else {
|
|
d.colText(d.text, nowR, y, state, uiInk2)
|
|
}
|
|
for k, s := range hv.slots {
|
|
c := uiCellNA
|
|
if s.sampled && s.noiseN > 0 {
|
|
c = uiNoise0
|
|
if 2*s.noiseOn >= s.noiseN {
|
|
c = uiNoise
|
|
}
|
|
}
|
|
d.heatCell(y, k, c)
|
|
}
|
|
if nv.missing > 0 {
|
|
d.colMark(resetR, y, markCross, uiCrit)
|
|
} else {
|
|
d.colMark(resetR, y, markCheck, uiGood)
|
|
}
|
|
default:
|
|
f := i - 1
|
|
if n := faultClasses[f].get(v.window); n > 0 {
|
|
d.colText(d.textB, nowR, y, scaleCount(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, scaleCount(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
|
|
}
|
|
|
|
// 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.small, scaleCount(v.rxFrames), uiInk2}, {d.small, " pkts", uiMuted}},
|
|
{{d.small, scaleCount(v.rxBytes), uiInk2}, {d.small, "B", uiMuted}},
|
|
}
|
|
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, nv noiseView, hv histView, target float64) error {
|
|
fb := d.fb
|
|
fb.fill(uiBg)
|
|
|
|
d.drawHeader(v, phy, hv, time.Now())
|
|
d.hairline(d.headerH - 1)
|
|
d.drawColHeaders()
|
|
d.drawRows(v, phy, measuring, nv, hv, target)
|
|
d.drawAxis()
|
|
d.drawFooter(v, elapsed)
|
|
d.drawResetButton()
|
|
return fb.flush()
|
|
}
|