Panel rebuilt as the heat matrix: verdict header (ring + newest fault named with its age, cable cell top right), one row per check with NOW / last-90s-at-5s-a-cell / since-reset columns, noise as a blue context lane; history.go keeps the 18-slot wall-time ring (per-class fault deltas, SNR minima, rate verdicts, measuring gray) plus since-reset aggregates and fault ages that clear on reset; noise cycle grid-locked to the shared slot clock so cells never straddle a phase and flicker, presence granted at boot until the first up-phase verdict; check/cross stroked as capsule marks (font has neither), line rate is a pure verdict everywhere, footer is a three-line stat stack beside hold-to-reset; old panel/chip/stat-grid machinery deleted; mockups/ gitignored

This commit is contained in:
flamingcow
2026-08-17 12:22:55 -07:00
parent e3c3f945c5
commit fb4b4596e9
8 changed files with 641 additions and 337 deletions
+1
View File
@@ -1 +1,2 @@
/shots/ /shots/
/mockups/
+11 -10
View File
@@ -65,21 +65,22 @@ func drawFatal(cause error) error {
return err return err
} }
const margin, lineGap = 32, 8
fb.fill(uiBg) fb.fill(uiBg)
noteY := fb.h - spaceRow - body.lineH noteY := fb.h - margin - body.lineH
y := spaceRow y := margin
title.draw(fb, spaceRow, y-title.capTop, "FATAL", uiRed) title.draw(fb, margin, y-title.capTop, "FATAL", uiCrit)
y += title.lineH + spaceRow y += title.lineH + margin
for _, line := range wrapWords(cause.Error(), (fb.w-2*spaceRow)/body.cellW) { for _, line := range wrapWords(cause.Error(), (fb.w-2*margin)/body.cellW) {
if y+body.lineH > noteY-spaceRow { if y+body.lineH > noteY-margin {
body.draw(fb, spaceRow, y-body.capTop, "...", uiFg) body.draw(fb, margin, y-body.capTop, "...", uiInk)
break break
} }
body.draw(fb, spaceRow, y-body.capTop, line, uiFg) body.draw(fb, margin, y-body.capTop, line, uiInk)
y += body.lineH + spaceTight y += body.lineH + lineGap
} }
note := fmt.Sprintf("rebooting in %d minutes", int(fatalRebootDelay.Minutes())) 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() return fb.flush()
} }
+26
View File
@@ -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) 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. // Blends src over the existing pixel, with cov as 0-255 coverage.
func (fb *framebuffer) blend(x, y int, c rgb, cov uint8) { func (fb *framebuffer) blend(x, y int, c rgb, cov uint8) {
if x < 0 || y < 0 || x >= fb.w || y >= fb.h || cov == 0 { if x < 0 || y < 0 || x >= fb.w || y >= fb.h || cov == 0 {
+186
View File
@@ -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
}
+13 -6
View File
@@ -341,9 +341,9 @@ func (d *direction) counters(now counterSet) view {
// Nothing to hold or revert here: failures during a measure were never // Nothing to hold or revert here: failures during a measure were never
// counted, so the view is always the counters as they stand. // 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() 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 { func totalView(views []view) view {
@@ -773,12 +773,16 @@ func run(aName, bName string) (err error) {
} }
start := time.Now() 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 // 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 // are suppressed at their sources while it runs, so there is nothing to
// hide or revert afterwards. // hide or revert afterwards.
kickMeasure := func() { kickMeasure := func() {
if diag.kick(&done) { if diag.kick(&done) {
start = resetAll(dirs, modules) start = resetAll(dirs, modules)
hist.reset()
} }
} }
close(startTx) close(startTx)
@@ -811,9 +815,10 @@ func run(aName, bName string) (err error) {
for i, d := range dirs { for i, d := range dirs {
views[i] = d.displayView() views[i] = d.displayView()
} }
v, phy := totalView(views), phyView(diag, modules) v := totalView(views)
if err := disp.render(v, now.Sub(start), phy, phy, measuring := phyView(diag, modules)
noise.view()); err != nil { if err := disp.render(v, now.Sub(start), phy, measuring,
noise.view(), hist.view(), target); err != nil {
return err return err
} }
case now := <-tick.C: case now := <-tick.C:
@@ -821,7 +826,9 @@ func run(aName, bName string) (err error) {
for i, d := range dirs { for i, d := range dirs {
rows[i] = d.displayView() 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 _, m := range modules {
for _, n := range m.takeNotes() { for _, n := range m.takeNotes() {
fmt.Println(stats.rule(n)) fmt.Println(stats.rule(n))
+26 -22
View File
@@ -28,8 +28,6 @@ import (
const ( const (
noiseDriver = "i40e" noiseDriver = "i40e"
noiseFrameLen = 1514 noiseFrameLen = 1514
noiseUpSpan = 5 * time.Second
noiseDownSpan = 5 * time.Second
noiseFrameGap = 10 * time.Millisecond noiseFrameGap = 10 * time.Millisecond
noiseEther uint16 = etherBase + numStreams noiseEther uint16 = etherBase + numStreams
@@ -94,6 +92,10 @@ func newNoiser() (*noiser, error) {
} }
n := &noiser{eps: [2]endpoint{a, b}} 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}} { for i, p := range [][2]endpoint{{a, b}, {b, a}} {
fd, err := openTxSocket(p[0].idx) fd, err := openTxSocket(p[0].idx)
if err != nil { 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 // 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 // good as one the wire mangled. What matters is only ever what the test cable
// counted. // 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) { func (n *noiser) run(done *atomic.Bool) {
fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0)
if err != nil { if err != nil {
@@ -179,29 +185,27 @@ func (n *noiser) run(done *atomic.Bool) {
tick := time.NewTicker(noiseFrameGap) tick := time.NewTicker(noiseFrameGap)
defer tick.Stop() defer tick.Stop()
applied, wasUp, linked := false, false, false
for !done.Load() { for !done.Load() {
n.setLinks(fd, true) <-tick.C
n.radiating.Store(true) up := (time.Since(rateEpochStart)/histSlotSpan)%2 == 0
linked := false if !applied || up != wasUp {
for end := time.Now().Add(noiseUpSpan); time.Now().Before(end) && !done.Load(); { // A whole up phase with no link is many times the ~1s the wire
<-tick.C // needs to train, so at its end the silence is the cable's answer.
if !n.bothUp() { if applied && wasUp {
continue n.connected.Store(linked)
}
linked = true
n.connected.Store(true)
for i := range n.ports {
unix.Write(n.ports[i].fd, n.ports[i].frame)
} }
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 if !up || !n.bothUp() {
// to train, so by now the silence is the cable's answer. continue
n.connected.Store(linked) }
linked = true
n.setLinks(fd, false) n.connected.Store(true)
n.radiating.Store(false) for i := range n.ports {
for end := time.Now().Add(noiseDownSpan); time.Now().Before(end) && !done.Load(); { unix.Write(n.ports[i].fd, n.ports[i].frame)
<-tick.C
} }
} }
// Left up rather than wherever the cycle stopped, so a run never strands // Left up rather than wherever the cycle stopped, so a run never strands
+356 -266
View File
@@ -4,69 +4,66 @@ import (
"fmt" "fmt"
"math" "math"
"runtime/debug" "runtime/debug"
"strings"
"time" "time"
) )
var ( var (
uiBg = rgb{0x12, 0x14, 0x18} uiBg = rgb{0x0d, 0x0d, 0x0d}
uiOKFill = rgb{0x18, 0x42, 0x26} uiInk = rgb{0xf2, 0xf2, 0xf0}
uiOKEdge = rgb{0x3c, 0xe0, 0x70} uiInk2 = rgb{0xc3, 0xc2, 0xb7}
uiErrFil = rgb{0x54, 0x18, 0x1c} uiMuted = rgb{0x89, 0x87, 0x81}
uiErrEdg = rgb{0xff, 0x46, 0x46} uiHair = rgb{0x28, 0x28, 0x26}
uiFg = rgb{0xe6, 0xe8, 0xea} uiGood = rgb{0x0c, 0xa3, 0x0c}
uiDim = rgb{0x9a, 0xa2, 0xac} uiWarn = rgb{0xfa, 0xb2, 0x19}
uiCyan = rgb{0x5c, 0xc8, 0xe0} uiCrit = rgb{0xd0, 0x3b, 0x3b}
uiGreen = rgb{0x6c, 0xdc, 0x86} uiCellOK = rgb{0x0a, 0x5d, 0x0a}
uiAmber = rgb{0xe8, 0xc4, 0x52} uiCellNA = rgb{0x3a, 0x3a, 0x37}
uiRed = rgb{0xf0, 0x6b, 0x6b} uiNoise = rgb{0x1c, 0x5c, 0xab}
uiNoise0 = rgb{0x24, 0x24, 0x23}
uiAccent = rgb{0x39, 0x87, 0xe5}
) )
func classColor(c int) rgb { func classColor(c int) rgb {
switch c { switch c {
case clsGood: case clsGood:
return uiGreen return uiGood
case clsWarn: case clsWarn:
return uiAmber return uiWarn
case clsBad: 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 ( const (
step = 4 uiMargin = 24
uiColGap = 12
headerTop = 26
ringD = 88
ringEdge = 5
spaceTight = step * 2 cellH = 20
spaceGroup = step * 4 cellR = 4
spaceRow = step * 8 cellGapX = 3
) rowH = 46
const ( btnW = 220
uiMargin = spaceGroup btnH = 72
uiPad = spaceGroup btnRadius = 10
uiBorder = step * 3 btnBorder = 2
pairGap = spaceGroup
blockGap = spaceGroup
btnW = 300
btnH = 80
holdDuration = time.Second holdDuration = time.Second
versionSpotSide = 200 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 { type rect struct {
x, y, w, h int x, y, w, h int
} }
@@ -77,18 +74,28 @@ func (r rect) contains(x, y int) bool {
type display struct { type display struct {
fb *framebuffer fb *framebuffer
huge *textFace
big *textFace big *textFace
grid *textFace text *textFace
gridB *textFace textB *textFace
small *textFace
nowPanel rect headerH int
sincePanel rect rowYs []int
nowYs []int axisY int
sinceYs []int labelX int
resetBtn rect nowX int
holdStart time.Time nowW int
holdFrac float64 cellsX int
fired bool cellW int
cellsW int
resetX int
resetW int
resetBtn rect
holdStart time.Time
holdFrac float64
fired bool
version string version string
versionSpot rect versionSpot rect
@@ -106,9 +113,11 @@ func newDisplay() (*display, error) {
bold bool bold bool
size float64 size float64
}{ }{
{&d.huge, true, 40},
{&d.big, true, 36}, {&d.big, true, 36},
{&d.grid, false, 30}, {&d.text, false, 22},
{&d.gridB, true, 30}, {&d.textB, true, 22},
{&d.small, false, 17},
} { } {
face, err := loadFace(spec.bold, spec.size) face, err := loadFace(spec.bold, spec.size)
if err != nil { if err != nil {
@@ -117,11 +126,6 @@ func newDisplay() (*display, error) {
} }
*spec.dst = face *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 { if err := d.layout(fb.w, fb.h); err != nil {
fb.close() fb.close()
return nil, err return nil, err
@@ -130,55 +134,45 @@ func newDisplay() (*display, error) {
} }
func (d *display) layout(w, h int) error { func (d *display) layout(w, h int) error {
now := []int{d.statsH(d.big, 3), d.chipsH()} labelW := 0
since := []int{d.statsH(d.gridB, 4), d.statsH(d.gridB, 1), d.countsH(), btnH} for _, r := range panelRows {
labelW = max(labelW, len(r)*d.text.cellW)
// 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)
} }
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 cellsX := d.nowX + d.nowW + uiColGap
nowH := 2*uiBorder + sum(now) + (len(now)+1)*gap cellsW := d.resetX - uiColGap - cellsX
sinceH := 2*uiBorder + sum(since) + (len(since)+1)*gap d.cellW = (cellsW - (histSlots-1)*cellGapX) / histSlots
d.nowPanel = rect{uiMargin, uiMargin, inner, nowH} if d.cellW < 6 {
d.sincePanel = rect{uiMargin, uiMargin + nowH + blockGap, inner, sinceH} 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.headerH = headerTop + ringD + 24
d.sinceYs = stack(d.sincePanel.y+uiBorder+gap, since, gap) colHdrH := d.small.lineH + 18
d.resetBtn = rect{ d.rowYs = make([]int, len(panelRows))
x: d.sincePanel.x + (inner-btnW)/2, y := d.headerH + colHdrH
y: d.sinceYs[len(d.sinceYs)-1], for i := range panelRows {
w: btnW, d.rowYs[i] = y
h: btnH, 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} d.versionSpot = rect{w - versionSpotSide, 0, versionSpotSide, versionSpotSide}
return nil 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 // Lifting or sliding off cancels, and the press has to be released before it
// can arm again. // can arm again.
func (d *display) holdReset(x, y int, down bool, now time.Time) bool { 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 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. // something being reported.
func (d *display) drawResetButton() { func (d *display) drawResetButton() {
r := d.resetBtn r := d.resetBtn
d.fb.roundRect(r.x, r.y, r.w, r.h, chipRadius, uiCyan) d.fb.roundRect(r.x, r.y, r.w, r.h, btnRadius, uiAccent)
d.fb.roundRect(r.x+chipBorder, r.y+chipBorder, r.w-2*chipBorder, r.h-2*chipBorder, d.fb.roundRect(r.x+btnBorder, r.y+btnBorder, r.w-2*btnBorder, r.h-2*btnBorder,
chipRadius-chipBorder, uiBg) btnRadius-btnBorder, uiBg)
split := r.x + chipBorder split := r.x + btnBorder
if d.holdFrac > 0 { if d.holdFrac > 0 {
w := int(float64(r.w-2*chipBorder) * math.Min(d.holdFrac, 1)) w := int(float64(r.w-2*btnBorder) * math.Min(d.holdFrac, 1))
d.fb.roundRect(r.x+chipBorder, r.y+chipBorder, w, r.h-2*chipBorder, d.fb.roundRect(r.x+btnBorder, r.y+btnBorder, w, r.h-2*btnBorder,
chipRadius-chipBorder, uiCyan) btnRadius-btnBorder, uiAccent)
split += w split += w
} }
label, base := "RESET", uiCyan label, base := "RESET", uiAccent
if d.showVersion { if d.showVersion {
label, base = d.version, uiDim label, base = d.version, uiMuted
} }
lx := r.x + (r.w-len(label)*d.gridB.cellW)/2 lx := r.x + (r.w-len(label)*d.textB.cellW)/2
ly := r.y + (r.h-d.gridB.lineH)/2 - d.gridB.capTop 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 // The label straddles the fill, so each glyph takes the colour that reads
// against whatever is behind it. // against whatever is behind it.
for i, c := range label { for i, c := range label {
gx := lx + i*d.gridB.cellW gx := lx + i*d.textB.cellW
col := base col := base
if gx+d.gridB.cellW/2 < split { if gx+d.textB.cellW/2 < split {
col = uiBg 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() d.fb.close()
} }
// y is the top of the line as read, not the top of the cell, so text and a type seg struct {
// bordered box placed the same distance apart look it. Returns the top of the f *textFace
// next line, gapless — the caller owns the spacing below. s string
func (d *display) centerIn(f *textFace, x, w, y int, s string, col rgb) int { c rgb
f.draw(d.fb, x+(w-len([]rune(s))*f.cellW)/2, y-f.capTop, s, col)
return y + f.lineH
} }
type statCell struct { func (d *display) segs(x, y int, ss []seg) int {
value string for _, g := range ss {
label string g.f.draw(d.fb, x, y-g.f.capTop, g.s, g.c)
col rgb x += len([]rune(g.s)) * g.f.cellW
}
return x
} }
func (d *display) statPairH(vf *textFace) int { func (d *display) textRightAt(f *textFace, right, y int, s string, c rgb) {
return vf.lineH + pairGap + d.grid.lineH 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 } // 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) {
func (d *display) statsH(vf *textFace, n int) int { t := math.Max(2.4, s*0.16)
return gridRows(n)*(d.statPairH(vf)+statRowGap) - statRowGap 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 (d *display) cross(cx, cy int, s float64, c rgb) {
func gridCell(i, n, cols, x, w int) (cx, cw int) { t := math.Max(2.4, s*0.16)
cw = (w - (cols-1)*chipGap) / cols fx, fy := float64(cx), float64(cy)
inRow := min(n-(i/cols)*cols, cols) d.fb.stroke(fx-0.30*s, fy-0.30*s, fx+0.30*s, fy+0.30*s, t, c)
cx = x + (w-(inRow*cw+(inRow-1)*chipGap))/2 + (i%cols)*(cw+chipGap) d.fb.stroke(fx-0.30*s, fy+0.30*s, fx+0.30*s, fy-0.30*s, t, c)
return cx, cw
} }
// An empty value takes its space without drawing, so nothing below it moves func (d *display) hairline(y int) {
// when it arrives. d.fb.rect(uiMargin, y, d.fb.w-2*uiMargin, 1, uiHair)
func (d *display) stats(vf *textFace, x, w, y int, cells []statCell) int { }
for i, c := range cells {
if c.value == "" { // A mark or short value in the NOW/RESET columns, right-aligned to the
continue // 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)) if phy.metresClass == clsBad {
} good = false
word = phy.metres
var errRows = []struct { sub = []seg{{d.text, "cable fault", uiInk2}}
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)
} }
c := uiGreen
if recentCorrected > 0 { col := uiGood
c = uiAmber if !good {
col = uiCrit
} }
cx, cw, cy := d.chipAt(len(errRows), n, gridCols, x, w, y, d.chipH(), c) rx, ry := uiMargin, headerTop
d.centerIn(d.grid, cx, cw, cy+chipPadY, "corrected", c) d.fb.roundRect(rx, ry, ringD, ringD, ringD/2, col)
c = errColor(nv.missing) d.fb.roundRect(rx+ringEdge, ry+ringEdge, ringD-2*ringEdge, ringD-2*ringEdge,
cx, cw, cy = d.chipAt(len(errRows)+1, n, gridCols, x, w, y, d.chipH(), c) ringD/2-ringEdge, uiBg)
d.centerIn(d.grid, cx, cw, cy+chipPadY, "noise", c) if good {
return y + d.chipsH() 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 { func (d *display) drawColHeaders() {
for i, r := range errRows { y := d.headerH + 8
n := r.get(e) d.textRightAt(d.small, d.nowX+d.nowW, y, "NOW", uiMuted)
c := errColor(n) hdr := "LAST 90 S"
cx, cw, cy := d.chipAt(i, len(errRows), gridCols, x, w, y, d.countChipH(), c) hw := len([]rune(hdr)) * d.small.cellW
ty := d.centerIn(d.gridB, cx, cw, cy+chipPadY, scaleCount(n), c) + chipLineGap d.small.draw(d.fb, d.cellsX+(d.cellsW-hw)/2, y-d.small.capTop, hdr, uiMuted)
d.centerIn(d.grid, cx, cw, ty, r.label, c) d.textRightAt(d.small, d.resetX+d.resetW, y, "RESET", uiMuted)
} d.hairline(d.rowYs[0] - 1)
return y + d.countsH()
} }
func metresStat(phy phyDisplay) statCell { func (d *display) drawAxis() {
col := uiFg d.small.draw(d.fb, d.cellsX, d.axisY-d.small.capTop, "90s ago", uiMuted)
unit := "m" d.textRightAt(d.small, d.cellsX+d.cellsW, d.axisY, "now", uiMuted)
if phy.metresClass != clsGood {
col = classColor(phy.metresClass)
unit = "cable"
}
return statCell{phy.metres, unit, col}
} }
func (d *display) panel(p rect, bad bool) (int, int) { // One row of history cells: na for a slot with nothing believable (never
fill, edge := uiOKFill, uiOKEdge // 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 { if bad {
fill, edge = uiErrFil, uiErrEdg return c
} }
d.fb.rect(p.x, p.y, p.w, p.h, edge) return uiCellOK
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
} }
func errColor(n uint64) rgb { func (d *display) drawRows(v view, phy phyDisplay, measuring bool, nv noiseView,
if n == 0 { hv histView, target float64) {
return uiGreen 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 { // SNR is a number wherever it appears, tinted only when it is worth worrying
if !phy.haveSNR { // about.
return statCell{"-", "dB margin", uiDim} func snrInk(m float64) rgb {
if c := snrClass(m); c != clsGood {
return classColor(c)
} }
col := uiFg return uiInk
if c := snrClass(phy.worstMargin); c != clsGood {
col = classColor(c)
}
return statCell{fmt.Sprintf("%+.1f", phy.worstMargin), "dB margin", col}
} }
func correctedStat(v uint64) statCell { // One fact per line, stacked beside the reset button: short lines can never
col := uiFg // crowd it, whatever the values grow to.
if v > 0 { func (d *display) drawFooter(v view, elapsed time.Duration) {
col = uiAmber 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 := d.fb
fb.fill(uiBg) fb.fill(uiBg)
x, w := d.panel(d.nowPanel, v.window.total() > 0) d.drawHeader(v, phy, hv, time.Now())
d.stats(d.big, x, w, d.nowYs[0], []statCell{ d.hairline(d.headerH - 1)
{scaleSI(v.rxGbps * 1e9), "bits/s", uiFg}, d.drawColHeaders()
{scaleSI(v.rxPPS), "packets/s", uiFg}, d.drawRows(v, phy, measuring, nv, hv, target)
snrStat(phy), d.drawAxis()
}) d.drawFooter(v, elapsed)
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.drawResetButton() d.drawResetButton()
return fb.flush() return fb.flush()
} }
+22 -33
View File
@@ -2,41 +2,19 @@ package main
import "testing" import "testing"
func TestGridCellFullRow(t *testing.T) { func testDisplay(t *testing.T) *display {
x0, w0 := gridCell(0, 2, gridCols, 0, 100) t.Helper()
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) {
d := &display{} d := &display{}
for _, spec := range []struct { for _, spec := range []struct {
dst **textFace dst **textFace
bold bool bold bool
size float64 size float64
}{ }{
{&d.big, true, 40}, {&d.huge, true, 40},
{&d.grid, false, 34}, {&d.big, true, 36},
{&d.gridB, true, 34}, {&d.text, false, 22},
{&d.textB, true, 22},
{&d.small, false, 17},
} { } {
face, err := loadFace(spec.bold, spec.size) face, err := loadFace(spec.bold, spec.size)
if err != nil { if err != nil {
@@ -44,12 +22,23 @@ func TestPanelFitsScreen(t *testing.T) {
} }
*spec.dst = face *spec.dst = face
} }
return d
}
func TestPanelFitsScreen(t *testing.T) {
d := testDisplay(t)
if err := d.layout(600, 1024); err != nil { if err := d.layout(600, 1024); err != nil {
t.Fatal(err) t.Fatal(err)
} }
gap := d.nowYs[0] - (d.nowPanel.y + uiBorder) if d.cellW < 6 {
t.Logf("panel gap %dpx, since panel ends at %dpx", gap, d.sincePanel.y+d.sincePanel.h) t.Errorf("heat cells %dpx wide, want at least 6", d.cellW)
if gap < spaceTight {
t.Errorf("panel gap is %dpx, want at least %d", gap, spaceTight)
} }
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)
} }