diff --git a/.gitignore b/.gitignore index 75b7bc1..09d0ccd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /shots/ +/mockups/ diff --git a/fatal.go b/fatal.go index 74210e8..a541175 100644 --- a/fatal.go +++ b/fatal.go @@ -65,21 +65,22 @@ func drawFatal(cause error) error { return err } + const margin, lineGap = 32, 8 fb.fill(uiBg) - noteY := fb.h - spaceRow - body.lineH - y := spaceRow - title.draw(fb, spaceRow, y-title.capTop, "FATAL", uiRed) - y += title.lineH + spaceRow - for _, line := range wrapWords(cause.Error(), (fb.w-2*spaceRow)/body.cellW) { - if y+body.lineH > noteY-spaceRow { - body.draw(fb, spaceRow, y-body.capTop, "...", uiFg) + noteY := fb.h - margin - body.lineH + y := margin + title.draw(fb, margin, y-title.capTop, "FATAL", uiCrit) + y += title.lineH + margin + for _, line := range wrapWords(cause.Error(), (fb.w-2*margin)/body.cellW) { + if y+body.lineH > noteY-margin { + body.draw(fb, margin, y-body.capTop, "...", uiInk) break } - body.draw(fb, spaceRow, y-body.capTop, line, uiFg) - y += body.lineH + spaceTight + body.draw(fb, margin, y-body.capTop, line, uiInk) + y += body.lineH + lineGap } note := fmt.Sprintf("rebooting in %d minutes", int(fatalRebootDelay.Minutes())) - body.draw(fb, spaceRow, noteY-body.capTop, note, uiDim) + body.draw(fb, margin, noteY-body.capTop, note, uiMuted) return fb.flush() } diff --git a/fb.go b/fb.go index a38ba55..fd5b44a 100644 --- a/fb.go +++ b/fb.go @@ -432,6 +432,32 @@ func (fb *framebuffer) roundRect(x0, y0, w, h, r int, c rgb) { fb.rect(x0+w-r, y0+r, r-1, h-2*r, c) } +// A round-capped stroke between two points, drawn as a capsule: coverage from +// distance to the segment, so marks the font lacks (check, cross) match its +// antialiasing. +func (fb *framebuffer) stroke(x0, y0, x1, y1, t float64, c rgb) { + r := t / 2 + minx, maxx := int(math.Floor(math.Min(x0, x1)-r))-1, int(math.Ceil(math.Max(x0, x1)+r))+1 + miny, maxy := int(math.Floor(math.Min(y0, y1)-r))-1, int(math.Ceil(math.Max(y0, y1)+r))+1 + dx, dy := x1-x0, y1-y0 + len2 := dx*dx + dy*dy + for y := miny; y <= maxy; y++ { + for x := minx; x <= maxx; x++ { + px, py := float64(x)-x0, float64(y)-y0 + u := 0.0 + if len2 > 0 { + u = math.Max(0, math.Min(1, (px*dx+py*dy)/len2)) + } + ex, ey := px-u*dx, py-u*dy + cov := r - math.Sqrt(ex*ex+ey*ey) + 0.5 + if cov <= 0 { + continue + } + fb.blend(x, y, c, uint8(math.Min(cov, 1)*255)) + } + } +} + // Blends src over the existing pixel, with cov as 0-255 coverage. func (fb *framebuffer) blend(x, y int, c rgb, cov uint8) { if x < 0 || y < 0 || x >= fb.w || y >= fb.h || cov == 0 { diff --git a/history.go b/history.go new file mode 100644 index 0000000..f1dc5a6 --- /dev/null +++ b/history.go @@ -0,0 +1,186 @@ +package main + +import ( + "sync" + "time" +) + +// The panel's recent-history ring: 18 slots of 5 seconds, sampled once a +// second from the same figures the console row prints. Slots are wall-time +// and survive a reset — recently is recently, whatever the counters were +// re-based to — while the since-reset aggregates and fault ages clear. +const ( + histSlots = 18 + histSlotSpan = 5 * time.Second + histSpan = histSlots * histSlotSpan + + // Line rate is a verdict here, not a number: the headline is smeared and + // briefly gated at startup, so anything close to the target counts. + rateOKFrac = 0.98 +) + +var faultClasses = []struct { + label string + noun string + get func(errs) uint64 +}{ + {"lost", "loss", func(e errs) uint64 { return e.lost }}, + {"corrupt", "corrupt", func(e errs) uint64 { return e.corrupt }}, + {"link", "link fault", func(e errs) uint64 { return e.link }}, + {"internal", "internal", func(e errs) uint64 { return e.internal }}, +} + +type histSlot struct { + seq int64 + sampled bool + measuring bool + faults [4]uint64 + corrected uint64 + rateLow bool + haveSNR bool + snrMin float64 + noiseOn int + noiseN int +} + +type history struct { + mu sync.Mutex + start time.Time + slots [histSlots]histSlot + seq int64 + + prevValid bool + prevSince errs + prevCorrected uint64 + + lastFault [4]time.Time + sinceRateLow bool + sinceHaveSNR bool + sinceSNRMin float64 +} + +func newHistory(now time.Time) *history { + return &history{start: now} +} + +func (h *history) slot(seq int64) *histSlot { + s := &h.slots[seq%histSlots] + if s.seq != seq || !s.sampled { + *s = histSlot{seq: seq, sampled: true} + } + return s +} + +// Counters only move forward between samples except across a reset, where +// they re-base to zero; a backward step is that re-base, not negative faults. +func delta(now, prev uint64) uint64 { + if now < prev { + return 0 + } + return now - prev +} + +func (h *history) sample(now time.Time, v view, phy phyDisplay, nv noiseView, + measuring bool, target float64) { + h.mu.Lock() + defer h.mu.Unlock() + + h.seq = int64(now.Sub(h.start) / histSlotSpan) + s := h.slot(h.seq) + + if h.prevValid { + for i, c := range faultClasses { + d := delta(c.get(v.since), c.get(h.prevSince)) + s.faults[i] += d + if d > 0 { + h.lastFault[i] = now + } + } + s.corrected += delta(phy.corrected, h.prevCorrected) + } + h.prevValid = true + h.prevSince = v.since + h.prevCorrected = phy.corrected + + if measuring { + s.measuring = true + } else { + if v.rxGbps < rateOKFrac*target { + s.rateLow = true + h.sinceRateLow = true + } + if phy.haveSNR { + if !s.haveSNR || phy.worstMargin < s.snrMin { + s.haveSNR, s.snrMin = true, phy.worstMargin + } + if !h.sinceHaveSNR || phy.worstMargin < h.sinceSNRMin { + h.sinceHaveSNR, h.sinceSNRMin = true, phy.worstMargin + } + } + } + s.noiseN++ + if nv.on { + s.noiseOn++ + } +} + +// The wall-time slots stay: recently is recently. Everything judged against +// the reset origin clears. +func (h *history) reset() { + h.mu.Lock() + h.prevValid = false + h.lastFault = [4]time.Time{} + h.sinceRateLow = false + h.sinceHaveSNR = false + h.sinceSNRMin = 0 + h.mu.Unlock() +} + +type histView struct { + // Oldest first; a slot that predates the ring or was never sampled has + // sampled false and paints as unknown. + slots [histSlots]histSlot + lastFault [4]time.Time + sinceRateLow bool + sinceHaveSNR bool + sinceSNRMin float64 +} + +func (h *history) view() histView { + h.mu.Lock() + defer h.mu.Unlock() + var out histView + for k := 0; k < histSlots; k++ { + seq := h.seq - int64(histSlots-1-k) + if seq < 0 { + continue + } + s := h.slots[seq%histSlots] + if s.seq == seq && s.sampled { + out.slots[k] = s + } + } + out.lastFault = h.lastFault + out.sinceRateLow = h.sinceRateLow + out.sinceHaveSNR = h.sinceHaveSNR + out.sinceSNRMin = h.sinceSNRMin + return out +} + +// The newest fault on record names the verdict; the cable's own verdict +// outranks traffic faults because it is the thing under test. +func (hv histView) newestFault(now time.Time) (i int, age time.Duration, ok bool) { + best := -1 + for c, t := range hv.lastFault { + if t.IsZero() { + continue + } + if best < 0 || t.After(hv.lastFault[best]) { + best = c + } + } + if best < 0 { + return 0, 0, false + } + return best, now.Sub(hv.lastFault[best]), true +} diff --git a/main.go b/main.go index e5650ca..32ba5ea 100644 --- a/main.go +++ b/main.go @@ -341,9 +341,9 @@ func (d *direction) counters(now counterSet) view { // Nothing to hold or revert here: failures during a measure were never // counted, so the view is always the counters as they stand. -func phyView(diag *cableDiag, modules []*phyModule) phyDisplay { +func phyView(diag *cableDiag, modules []*phyModule) (phyDisplay, bool) { info, measuring := diag.snapshot() - return phyDisplayFrom(info, measuring, modules[0].view(), modules[1].view()) + return phyDisplayFrom(info, measuring, modules[0].view(), modules[1].view()), measuring } func totalView(views []view) view { @@ -773,12 +773,16 @@ func run(aName, bName string) (err error) { } start := time.Now() + // Anchored to the same clock as the noise cycle, so slot boundaries and + // phase transitions coincide. + hist := newHistory(rateEpochStart) // A reset resets at the press, then re-measures; the measure's failures // are suppressed at their sources while it runs, so there is nothing to // hide or revert afterwards. kickMeasure := func() { if diag.kick(&done) { start = resetAll(dirs, modules) + hist.reset() } } close(startTx) @@ -811,9 +815,10 @@ func run(aName, bName string) (err error) { for i, d := range dirs { views[i] = d.displayView() } - v, phy := totalView(views), phyView(diag, modules) - if err := disp.render(v, now.Sub(start), phy, - noise.view()); err != nil { + v := totalView(views) + phy, measuring := phyView(diag, modules) + if err := disp.render(v, now.Sub(start), phy, measuring, + noise.view(), hist.view(), target); err != nil { return err } case now := <-tick.C: @@ -821,7 +826,9 @@ func run(aName, bName string) (err error) { for i, d := range dirs { rows[i] = d.displayView() } - v, phy := totalView(rows), phyView(diag, modules) + v := totalView(rows) + phy, measuring := phyView(diag, modules) + hist.sample(now, v, phy, noise.view(), measuring, target) for _, m := range modules { for _, n := range m.takeNotes() { fmt.Println(stats.rule(n)) diff --git a/noise.go b/noise.go index 8650bae..5e8893e 100644 --- a/noise.go +++ b/noise.go @@ -28,8 +28,6 @@ import ( const ( noiseDriver = "i40e" noiseFrameLen = 1514 - noiseUpSpan = 5 * time.Second - noiseDownSpan = 5 * time.Second noiseFrameGap = 10 * time.Millisecond noiseEther uint16 = etherBase + numStreams @@ -94,6 +92,10 @@ func newNoiser() (*noiser, error) { } n := &noiser{eps: [2]endpoint{a, b}} + // The grid-locked cycle can start mid-down-slot, where absence would be + // our own doing: presence is granted until the first up phase delivers a + // verdict. + n.connected.Store(true) for i, p := range [][2]endpoint{{a, b}, {b, a}} { fd, err := openTxSocket(p[0].idx) if err != nil { @@ -169,6 +171,10 @@ func (n *noiser) bothUp() bool { // comes and goes under the cycle, and a frame this side declined to send is as // good as one the wire mangled. What matters is only ever what the test cable // counted. +// +// The cycle is locked to the panel's history grid — up on even 5 s slots, +// down on odd, transitions on the shared slot boundaries — so a history cell +// never straddles a phase and flickers between the two answers. func (n *noiser) run(done *atomic.Bool) { fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) if err != nil { @@ -179,29 +185,27 @@ func (n *noiser) run(done *atomic.Bool) { tick := time.NewTicker(noiseFrameGap) defer tick.Stop() + applied, wasUp, linked := false, false, false for !done.Load() { - n.setLinks(fd, true) - n.radiating.Store(true) - linked := false - for end := time.Now().Add(noiseUpSpan); time.Now().Before(end) && !done.Load(); { - <-tick.C - if !n.bothUp() { - continue - } - linked = true - n.connected.Store(true) - for i := range n.ports { - unix.Write(n.ports[i].fd, n.ports[i].frame) + <-tick.C + up := (time.Since(rateEpochStart)/histSlotSpan)%2 == 0 + if !applied || up != wasUp { + // A whole up phase with no link is many times the ~1s the wire + // needs to train, so at its end the silence is the cable's answer. + if applied && wasUp { + n.connected.Store(linked) } + n.setLinks(fd, up) + n.radiating.Store(up) + applied, wasUp, linked = true, up, false } - // A whole up phase with no link is many times the ~1s the wire needs - // to train, so by now the silence is the cable's answer. - n.connected.Store(linked) - - n.setLinks(fd, false) - n.radiating.Store(false) - for end := time.Now().Add(noiseDownSpan); time.Now().Before(end) && !done.Load(); { - <-tick.C + if !up || !n.bothUp() { + continue + } + linked = true + n.connected.Store(true) + for i := range n.ports { + unix.Write(n.ports[i].fd, n.ports[i].frame) } } // Left up rather than wherever the cycle stopped, so a run never strands diff --git a/ui.go b/ui.go index 382860c..eec9682 100644 --- a/ui.go +++ b/ui.go @@ -4,69 +4,66 @@ import ( "fmt" "math" "runtime/debug" + "strings" "time" ) var ( - uiBg = rgb{0x12, 0x14, 0x18} - uiOKFill = rgb{0x18, 0x42, 0x26} - uiOKEdge = rgb{0x3c, 0xe0, 0x70} - uiErrFil = rgb{0x54, 0x18, 0x1c} - uiErrEdg = rgb{0xff, 0x46, 0x46} - uiFg = rgb{0xe6, 0xe8, 0xea} - uiDim = rgb{0x9a, 0xa2, 0xac} - uiCyan = rgb{0x5c, 0xc8, 0xe0} - uiGreen = rgb{0x6c, 0xdc, 0x86} - uiAmber = rgb{0xe8, 0xc4, 0x52} - uiRed = rgb{0xf0, 0x6b, 0x6b} + 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 uiGreen + return uiGood case clsWarn: - return uiAmber + return uiWarn case clsBad: - return uiRed + return uiCrit } - return uiDim + return uiMuted } -// Distances between ink, since layout measures a line from the top of a digit -// to the baseline rather than across a cell with accent and descender slack in -// it. Values carried over from spacing cells will look too small here. const ( - step = 4 + uiMargin = 24 + uiColGap = 12 + headerTop = 26 + ringD = 88 + ringEdge = 5 - spaceTight = step * 2 - spaceGroup = step * 4 - spaceRow = step * 8 -) + cellH = 20 + cellR = 4 + cellGapX = 3 + rowH = 46 -const ( - uiMargin = spaceGroup - uiPad = spaceGroup - uiBorder = step * 3 - pairGap = spaceGroup - blockGap = spaceGroup - - btnW = 300 - btnH = 80 + btnW = 220 + btnH = 72 + btnRadius = 10 + btnBorder = 2 holdDuration = time.Second versionSpotSide = 200 - - chipRadius = spaceTight - chipBorder = 2 - - gridCols = 2 - chipPadY = step * 4 - chipLineGap = step * 3 - chipGap = spaceTight - statRowGap = spaceRow ) +// 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 } @@ -77,18 +74,28 @@ func (r rect) contains(x, y int) bool { type display struct { fb *framebuffer + huge *textFace big *textFace - grid *textFace - gridB *textFace + text *textFace + textB *textFace + small *textFace - nowPanel rect - sincePanel rect - nowYs []int - sinceYs []int - resetBtn rect - holdStart time.Time - holdFrac float64 - fired bool + 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 @@ -106,9 +113,11 @@ func newDisplay() (*display, error) { bold bool size float64 }{ + {&d.huge, true, 40}, {&d.big, true, 36}, - {&d.grid, false, 30}, - {&d.gridB, true, 30}, + {&d.text, false, 22}, + {&d.textB, true, 22}, + {&d.small, false, 17}, } { face, err := loadFace(spec.bold, spec.size) if err != nil { @@ -117,11 +126,6 @@ func newDisplay() (*display, error) { } *spec.dst = face } - if d.grid.cellW != d.gridB.cellW { - fb.close() - return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d", - d.grid.cellW, d.gridB.cellW) - } if err := d.layout(fb.w, fb.h); err != nil { fb.close() return nil, err @@ -130,55 +134,45 @@ func newDisplay() (*display, error) { } func (d *display) layout(w, h int) error { - now := []int{d.statsH(d.big, 3), d.chipsH()} - since := []int{d.statsH(d.gridB, 4), d.statsH(d.gridB, 1), d.countsH(), btnH} - - // One gap for both panels, and vertically the frame is the border alone. - // Insetting by uiPad as well would add it to the gaps at a panel's ends but - // not to the ones between blocks, which is not equal spacing however evenly - // the remainder is divided. - gaps := len(now) + len(since) + 2 - spare := h - 2*uiMargin - blockGap - 4*uiBorder - sum(now) - sum(since) - if spare < 0 { - return fmt.Errorf("panel content is %dpx taller than the screen", -spare) + labelW := 0 + for _, r := range panelRows { + labelW = max(labelW, len(r)*d.text.cellW) } - gap := spare / gaps + 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 - inner := w - 2*uiMargin - nowH := 2*uiBorder + sum(now) + (len(now)+1)*gap - sinceH := 2*uiBorder + sum(since) + (len(since)+1)*gap - d.nowPanel = rect{uiMargin, uiMargin, inner, nowH} - d.sincePanel = rect{uiMargin, uiMargin + nowH + blockGap, inner, sinceH} + 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.nowYs = stack(d.nowPanel.y+uiBorder+gap, now, gap) - d.sinceYs = stack(d.sincePanel.y+uiBorder+gap, since, gap) - d.resetBtn = rect{ - x: d.sincePanel.x + (inner-btnW)/2, - y: d.sinceYs[len(d.sinceYs)-1], - w: btnW, - h: btnH, + 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 } -func sum(hs []int) int { - var t int - for _, h := range hs { - t += h - } - return t -} - -func stack(y int, hs []int, gap int) []int { - ys := make([]int, len(hs)) - for i, h := range hs { - ys[i] = y - y += h + gap - } - return ys -} - // 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 { @@ -201,37 +195,37 @@ func (d *display) holdReset(x, y int, down bool, now time.Time) bool { return true } -// Cyan rather than the status colours because it is something to press, not +// 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, chipRadius, uiCyan) - d.fb.roundRect(r.x+chipBorder, r.y+chipBorder, r.w-2*chipBorder, r.h-2*chipBorder, - chipRadius-chipBorder, uiBg) + 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 + chipBorder + split := r.x + btnBorder if d.holdFrac > 0 { - w := int(float64(r.w-2*chipBorder) * math.Min(d.holdFrac, 1)) - d.fb.roundRect(r.x+chipBorder, r.y+chipBorder, w, r.h-2*chipBorder, - chipRadius-chipBorder, uiCyan) + 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", uiCyan + label, base := "RESET", uiAccent if d.showVersion { - label, base = d.version, uiDim + label, base = d.version, uiMuted } - lx := r.x + (r.w-len(label)*d.gridB.cellW)/2 - ly := r.y + (r.h-d.gridB.lineH)/2 - d.gridB.capTop + 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.gridB.cellW + gx := lx + i*d.textB.cellW col := base - if gx+d.gridB.cellW/2 < split { + if gx+d.textB.cellW/2 < split { col = uiBg } - d.gridB.draw(d.fb, gx, ly, string(c), col) + d.textB.draw(d.fb, gx, ly, string(c), col) } } @@ -267,194 +261,290 @@ func (d *display) close() { d.fb.close() } -// y is the top of the line as read, not the top of the cell, so text and a -// bordered box placed the same distance apart look it. Returns the top of the -// next line, gapless — the caller owns the spacing below. -func (d *display) centerIn(f *textFace, x, w, y int, s string, col rgb) int { - f.draw(d.fb, x+(w-len([]rune(s))*f.cellW)/2, y-f.capTop, s, col) - return y + f.lineH +type seg struct { + f *textFace + s string + c rgb } -type statCell struct { - value string - label string - col 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) statPairH(vf *textFace) int { - return vf.lineH + pairGap + d.grid.lineH +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) } -func gridRows(n int) int { return (n + gridCols - 1) / gridCols } - -func (d *display) statsH(vf *textFace, n int) int { - return gridRows(n)*(d.statPairH(vf)+statRowGap) - statRowGap +// 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) } -// A last row that does not fill the grid is centred. -func gridCell(i, n, cols, x, w int) (cx, cw int) { - cw = (w - (cols-1)*chipGap) / cols - inRow := min(n-(i/cols)*cols, cols) - cx = x + (w-(inRow*cw+(inRow-1)*chipGap))/2 + (i%cols)*(cw+chipGap) - return cx, cw +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) } -// An empty value takes its space without drawing, so nothing below it moves -// when it arrives. -func (d *display) stats(vf *textFace, x, w, y int, cells []statCell) int { - for i, c := range cells { - if c.value == "" { - continue +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}, } - cx, cw := gridCell(i, len(cells), gridCols, x, w) - cy := y + (i/gridCols)*(d.statPairH(vf)+statRowGap) - ly := d.centerIn(vf, cx, cw, cy, c.value, c.col) + pairGap - d.centerIn(d.grid, cx, cw, ly, c.label, uiDim) } - return y + d.statsH(vf, len(cells)) -} - -var errRows = []struct { - label string - get func(errs) uint64 -}{ - {"lost", func(e errs) uint64 { return e.lost }}, - {"corrupt", func(e errs) uint64 { return e.corrupt }}, - {"link", func(e errs) uint64 { return e.link }}, - {"internal", func(e errs) uint64 { return e.internal }}, -} - -func (d *display) chipH() int { return d.grid.lineH + 2*chipPadY } - -func (d *display) countChipH() int { return d.chipH() + d.gridB.lineH + chipLineGap } - -// The error chips plus corrected and the noise cable's, which share their -// row grid. -func (d *display) chipsH() int { - return gridRows(len(errRows)+2)*(d.chipH()+chipGap) - chipGap -} - -func (d *display) countsH() int { - return gridRows(len(errRows))*(d.countChipH()+chipGap) - chipGap -} - -// Outlined by drawing the border colour and sinking a smaller well of -// background into it, so both curves get the same antialiasing. -func (d *display) chipAt(i, n, cols, x, w, y, h int, c rgb) (int, int, int) { - cx, cw := gridCell(i, n, cols, x, w) - cy := y + (i/cols)*(h+chipGap) - - d.fb.roundRect(cx, cy, cw, h, chipRadius, c) - d.fb.roundRect(cx+chipBorder, cy+chipBorder, - cw-2*chipBorder, h-2*chipBorder, chipRadius-chipBorder, uiBg) - return cx, cw, cy -} - -// Whether rather than how many: over a window this short a count changes faster -// than it can be read. The noise chip rides along at the end, presence rather -// than health: red is the cable missing, not the cable failing. The cycle -// phase stays off the panel (the console column still carries it). -func (d *display) errChips(x, w, y int, e errs, recentCorrected uint64, nv noiseView) int { - n := len(errRows) + 2 - for i, r := range errRows { - c := errColor(r.get(e)) - cx, cw, cy := d.chipAt(i, n, gridCols, x, w, y, d.chipH(), c) - d.centerIn(d.grid, cx, cw, cy+chipPadY, r.label, c) + if phy.metresClass == clsBad { + good = false + word = phy.metres + sub = []seg{{d.text, "cable fault", uiInk2}} } - c := uiGreen - if recentCorrected > 0 { - c = uiAmber + + col := uiGood + if !good { + col = uiCrit } - cx, cw, cy := d.chipAt(len(errRows), n, gridCols, x, w, y, d.chipH(), c) - d.centerIn(d.grid, cx, cw, cy+chipPadY, "corrected", c) - c = errColor(nv.missing) - cx, cw, cy = d.chipAt(len(errRows)+1, n, gridCols, x, w, y, d.chipH(), c) - d.centerIn(d.grid, cx, cw, cy+chipPadY, "noise", c) - return y + d.chipsH() + 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) errCounts(x, w, y int, e errs) int { - for i, r := range errRows { - n := r.get(e) - c := errColor(n) - cx, cw, cy := d.chipAt(i, len(errRows), gridCols, x, w, y, d.countChipH(), c) - ty := d.centerIn(d.gridB, cx, cw, cy+chipPadY, scaleCount(n), c) + chipLineGap - d.centerIn(d.grid, cx, cw, ty, r.label, c) - } - return y + d.countsH() +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 metresStat(phy phyDisplay) statCell { - col := uiFg - unit := "m" - if phy.metresClass != clsGood { - col = classColor(phy.metresClass) - unit = "cable" - } - return statCell{phy.metres, unit, col} +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) } -func (d *display) panel(p rect, bad bool) (int, int) { - fill, edge := uiOKFill, uiOKEdge +// 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 { - fill, edge = uiErrFil, uiErrEdg + return c } - d.fb.rect(p.x, p.y, p.w, p.h, edge) - d.fb.rect(p.x+uiBorder, p.y+uiBorder, - p.w-2*uiBorder, p.h-2*uiBorder, fill) - - inset := uiBorder + uiPad - return p.x + inset, p.w - 2*inset + return uiCellOK } -func errColor(n uint64) rgb { - if n == 0 { - return uiGreen +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) + } + } } - return uiRed } -func snrStat(phy phyDisplay) statCell { - if !phy.haveSNR { - return statCell{"-", "dB margin", uiDim} +// 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) } - col := uiFg - if c := snrClass(phy.worstMargin); c != clsGood { - col = classColor(c) - } - return statCell{fmt.Sprintf("%+.1f", phy.worstMargin), "dB margin", col} + return uiInk } -func correctedStat(v uint64) statCell { - col := uiFg - if v > 0 { - col = uiAmber +// 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 } - return statCell{scaleCount(v), "corrected", col} } -func (d *display) render(v view, elapsed time.Duration, phy phyDisplay, nv noiseView) error { +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) - x, w := d.panel(d.nowPanel, v.window.total() > 0) - d.stats(d.big, x, w, d.nowYs[0], []statCell{ - {scaleSI(v.rxGbps * 1e9), "bits/s", uiFg}, - {scaleSI(v.rxPPS), "packets/s", uiFg}, - snrStat(phy), - }) - d.errChips(x, w, d.nowYs[1], v.window, phy.recent, nv) - - x, w = d.panel(d.sincePanel, v.since.total() > 0 || phy.metresClass == clsBad) - d.stats(d.gridB, x, w, d.sinceYs[0], []statCell{ - {scaleTime(elapsed), "elapsed", uiFg}, - {scaleCount(v.rxFrames), "packets", uiFg}, - {scaleCount(v.rxBytes), "bytes", uiFg}, - correctedStat(phy.corrected), - }) - d.stats(d.gridB, x, w, d.sinceYs[1], []statCell{metresStat(phy)}) - d.errCounts(x, w, d.sinceYs[2], v.since) - + 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() } diff --git a/ui_test.go b/ui_test.go index e0bfa26..dad4131 100644 --- a/ui_test.go +++ b/ui_test.go @@ -2,41 +2,19 @@ package main import "testing" -func TestGridCellFullRow(t *testing.T) { - x0, w0 := gridCell(0, 2, gridCols, 0, 100) - x1, w1 := gridCell(1, 2, gridCols, 0, 100) - if w0 != w1 { - t.Errorf("cells differ in width: %d vs %d", w0, w1) - } - if x0 != 0 { - t.Errorf("first cell x = %d, want 0", x0) - } - if x1+w1 != 100 { - t.Errorf("row ends at %d, want 100", x1+w1) - } - if got := x1 - (x0 + w0); got != chipGap { - t.Errorf("gap between cells = %d, want %d", got, chipGap) - } -} - -// A last row that does not fill the grid is centred. -func TestGridCellShortLastRow(t *testing.T) { - cx, cw := gridCell(2, 3, gridCols, 0, 100) - if left, right := cx, 100-(cx+cw); left != right { - t.Errorf("lone cell has %d left and %d right, want centred", left, right) - } -} - -func TestPanelFitsScreen(t *testing.T) { +func testDisplay(t *testing.T) *display { + t.Helper() d := &display{} for _, spec := range []struct { dst **textFace bold bool size float64 }{ - {&d.big, true, 40}, - {&d.grid, false, 34}, - {&d.gridB, true, 34}, + {&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 { @@ -44,12 +22,23 @@ func TestPanelFitsScreen(t *testing.T) { } *spec.dst = face } + return d +} + +func TestPanelFitsScreen(t *testing.T) { + d := testDisplay(t) if err := d.layout(600, 1024); err != nil { t.Fatal(err) } - gap := d.nowYs[0] - (d.nowPanel.y + uiBorder) - t.Logf("panel gap %dpx, since panel ends at %dpx", gap, d.sincePanel.y+d.sincePanel.h) - if gap < spaceTight { - t.Errorf("panel gap is %dpx, want at least %d", gap, spaceTight) + if d.cellW < 6 { + t.Errorf("heat cells %dpx wide, want at least 6", d.cellW) } + if end := d.cellsX + d.cellsW; end > d.resetX { + t.Errorf("heat cells run to %d, past the reset column at %d", end, d.resetX) + } + if last := d.rowYs[len(d.rowYs)-1] + rowH; last > d.resetBtn.y { + t.Errorf("rows end at %d, past the footer at %d", last, d.resetBtn.y) + } + t.Logf("cells %dpx wide, rows end at %d, footer at %d", + d.cellW, d.axisY+d.small.lineH, d.resetBtn.y) }