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 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 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) } // 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 - d.small.lineH - 8 - 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.axisY = y + 8 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) 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) } 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, 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 } // 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, 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.drawAxis() d.drawFooter(v, elapsed) d.drawResetButton() return fb.flush() }