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:
@@ -1 +1,2 @@
|
||||
/shots/
|
||||
/mockups/
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+186
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+22
-33
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user