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() }