Compare commits

...
3 Commits
7 changed files with 307 additions and 253 deletions
+35 -20
View File
@@ -31,6 +31,9 @@ const (
viScreenInfoLen = 40 viScreenInfoLen = 40
) )
// w and h are the logical canvas, which is portrait; pw and ph are the panel,
// which is landscape. Every draw is turned a quarter turn on its way to memory,
// so logical top lands on the panel's right edge.
type framebuffer struct { type framebuffer struct {
file *os.File file *os.File
tty *os.File tty *os.File
@@ -38,12 +41,22 @@ type framebuffer struct {
back []byte back []byte
w int w int
h int h int
pw int
ph int
stride int stride int
rShift uint rShift uint
gShift uint gShift uint
bShift uint bShift uint
} }
func (fb *framebuffer) offset(x, y int) int {
return x*fb.stride + (fb.pw-1-y)*4
}
func (fb *framebuffer) fromPanel(x, y int) (int, int) {
return y, fb.pw - 1 - x
}
// The kernel console draws into the same framebuffer, including a blinking // The kernel console draws into the same framebuffer, including a blinking
// cursor and any keyboard echo, which fights every frame we write. Putting the // cursor and any keyboard echo, which fights every frame we write. Putting the
// active console into graphics mode stops it touching the framebuffer at all. // active console into graphics mode stops it touching the framebuffer at all.
@@ -88,21 +101,23 @@ func openFramebuffer(path string) (*framebuffer, error) {
fb := &framebuffer{ fb := &framebuffer{
file: f, file: f,
w: int(vi[viXres]), pw: int(vi[viXres]),
h: int(vi[viYres]), ph: int(vi[viYres]),
w: int(vi[viYres]),
h: int(vi[viXres]),
stride: int(stride), stride: int(stride),
rShift: uint(vi[viRedOffset]), rShift: uint(vi[viRedOffset]),
gShift: uint(vi[viGreenOffset]), gShift: uint(vi[viGreenOffset]),
bShift: uint(vi[viBlueOffset]), bShift: uint(vi[viBlueOffset]),
} }
fb.mem, err = unix.Mmap(int(f.Fd()), 0, fb.stride*fb.h, fb.mem, err = unix.Mmap(int(f.Fd()), 0, fb.stride*fb.ph,
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED) unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil { if err != nil {
f.Close() f.Close()
return nil, fmt.Errorf("mmap: %w", err) return nil, fmt.Errorf("mmap: %w", err)
} }
fb.back = make([]byte, fb.stride*fb.h) fb.back = make([]byte, fb.stride*fb.ph)
fb.tty, err = takeConsole() fb.tty, err = takeConsole()
if err != nil { if err != nil {
@@ -139,28 +154,28 @@ func (fb *framebuffer) fill(c rgb) {
row[x+2] = byte(v >> 16) row[x+2] = byte(v >> 16)
row[x+3] = byte(v >> 24) row[x+3] = byte(v >> 24)
} }
for y := 0; y < fb.h; y++ { for y := 0; y < fb.ph; y++ {
copy(fb.back[y*fb.stride:], row) copy(fb.back[y*fb.stride:], row)
} }
} }
// One logical column is contiguous after the turn, so it fills a span at a time.
func (fb *framebuffer) rect(x0, y0, w, h int, c rgb) { func (fb *framebuffer) rect(x0, y0, w, h int, c rgb) {
x1, y1 := min(x0+w, fb.w), min(y0+h, fb.h)
x0, y0 = max(x0, 0), max(y0, 0)
if x0 >= x1 || y0 >= y1 {
return
}
v := fb.pixel(c) v := fb.pixel(c)
for y := y0; y < y0+h; y++ { span := make([]byte, (y1-y0)*4)
if y < 0 || y >= fb.h { for i := 0; i+4 <= len(span); i += 4 {
continue span[i+0] = byte(v)
} span[i+1] = byte(v >> 8)
base := y * fb.stride span[i+2] = byte(v >> 16)
for x := x0; x < x0+w; x++ { span[i+3] = byte(v >> 24)
if x < 0 || x >= fb.w {
continue
}
o := base + x*4
fb.back[o+0] = byte(v)
fb.back[o+1] = byte(v >> 8)
fb.back[o+2] = byte(v >> 16)
fb.back[o+3] = byte(v >> 24)
} }
for x := x0; x < x1; x++ {
copy(fb.back[fb.offset(x, y1-1):], span)
} }
} }
@@ -169,7 +184,7 @@ 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 {
return return
} }
o := y*fb.stride + x*4 o := fb.offset(x, y)
if cov == 255 { if cov == 255 {
v := fb.pixel(c) v := fb.pixel(c)
fb.back[o+0] = byte(v) fb.back[o+0] = byte(v)
-8
View File
@@ -60,14 +60,6 @@ var patterns = []pattern{
}}, }},
} }
func patternNames() []string {
names := make([]string, len(patterns))
for i, p := range patterns {
names[i] = p.name
}
return names
}
// Every pattern is laid out at full length up front with its checksum at each // Every pattern is laid out at full length up front with its checksum at each
// size, so a sender picks a pattern by choosing a buffer, never by writing one. // size, so a sender picks a pattern by choosing a buffer, never by writing one.
type frameSpec struct { type frameSpec struct {
+4 -3
View File
@@ -3,7 +3,8 @@ module g.fc.run/theater/cabletest
go 1.26.5 go 1.26.5
require ( require (
golang.org/x/image v0.44.0 // indirect golang.org/x/image v0.44.0
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0 // indirect
) )
require golang.org/x/text v0.40.0 // indirect
+138 -46
View File
@@ -58,8 +58,14 @@ type direction struct {
drops uint64 drops uint64
errBase sample errBase sample
dropBase uint64 dropBase uint64
nicRaw uint64
nicNow uint64 nicNow uint64
nicBase uint64 nicBase uint64
recent errs
recentBase sample
recentDrops uint64
recentNic uint64
} }
// Smoothing has to be steady against high-frequency noise yet still chase a // Smoothing has to be steady against high-frequency noise yet still chase a
@@ -84,7 +90,13 @@ type rateEstimator struct {
n int n int
} }
func (e *rateEstimator) update(x float64) { // While the rate window is still growing it already averages everything there
// is, so smoothing it again would only average in the startup ramp twice.
func (e *rateEstimator) update(x float64, windowFull bool) {
if !windowFull {
e.est, e.shown, e.mad, e.n = x, x, 0, 0
return
}
if e.n == 0 { if e.n == 0 {
e.est, e.shown, e.n = x, x, 1 e.est, e.shown, e.n = x, x, 1
return return
@@ -147,6 +159,27 @@ type rateSample struct {
txFrames, txBytes, rxFrames, rxBytes uint64 txFrames, txBytes, rxFrames, rxBytes uint64
} }
// Late is counted but left out of the total, since a reordered frame arrived.
type errs struct {
lost, late uint64
crc, badMagic uint64
badLen uint64
kdrop, link uint64
}
func (e errs) total() uint64 {
return e.lost + e.crc + e.badMagic + e.badLen + e.kdrop + e.link
}
func (e errs) add(o errs) errs {
return errs{
lost: e.lost + o.lost, late: e.late + o.late,
crc: e.crc + o.crc, badMagic: e.badMagic + o.badMagic,
badLen: e.badLen + o.badLen,
kdrop: e.kdrop + o.kdrop, link: e.link + o.link,
}
}
// A sliding window: the counters are sampled every frame and the rate is taken // A sliding window: the counters are sampled every frame and the rate is taken
// across the whole window, so the figure moves every frame while still being // across the whole window, so the figure moves every frame while still being
// measured over a long enough span to be steady. // measured over a long enough span to be steady.
@@ -313,12 +346,9 @@ var intervalCols = []colSpec{
type view struct { type view struct {
txPPS, rxPPS float64 txPPS, rxPPS float64
txGbps, rxGbps float64 txGbps, rxGbps float64
txFrames, txSent uint64
rxFrames, rxGot uint64 rxFrames, rxGot uint64
lost, late uint64 since errs
crc, badMagic uint64 window errs
kdrop, link uint64
errors uint64
cable cableView cable cableView
} }
@@ -326,25 +356,39 @@ type view struct {
// console and the display. // console and the display.
func (d *direction) counters(now sample) view { func (d *direction) counters(now sample) view {
b := d.errBase b := d.errBase
v := view{ return view{
txFrames: now.txFrames - b.txFrames,
txSent: now.txBytes - b.txBytes,
rxFrames: now.rxFrames - b.rxFrames, rxFrames: now.rxFrames - b.rxFrames,
rxGot: now.rxBytes - b.rxBytes, rxGot: now.rxBytes - b.rxBytes,
cable: d.cable.view(),
since: errs{
lost: now.lost - b.lost, lost: now.lost - b.lost,
late: now.late - b.late, late: now.late - b.late,
crc: now.crcErr - b.crcErr, crc: now.crcErr - b.crcErr,
badMagic: now.badMagic - b.badMagic, badMagic: now.badMagic - b.badMagic,
badLen: now.badLen - b.badLen,
kdrop: d.drops - d.dropBase, kdrop: d.drops - d.dropBase,
cable: d.cable.view(),
}
// A frame the stack refused and a frame the driver dropped are the same // A frame the stack refused and a frame the driver dropped are the same
// failure seen from either side of the ring, and never the same frame twice: // failure seen from either side of the ring, and never the same frame
// a send that fails never reaches the driver to be dropped. // twice: a send that fails never reaches the driver to be dropped.
v.link = d.nicNow - d.nicBase + link: d.nicNow - d.nicBase +
(now.txErrs - b.txErrs) + (now.rxErrs - b.rxErrs) (now.txErrs - b.txErrs) + (now.rxErrs - b.rxErrs),
v.errors = v.lost + v.crc + v.badMagic + (now.badLen - b.badLen) + v.kdrop + v.link },
return v }
}
func totalView(views []view) view {
var t view
for _, v := range views {
t.txPPS += v.txPPS
t.rxPPS += v.rxPPS
t.txGbps += v.txGbps
t.rxGbps += v.rxGbps
t.rxFrames += v.rxFrames
t.rxGot += v.rxGot
t.since = t.since.add(v.since)
t.window = t.window.add(v.window)
}
return t
} }
func (d *direction) view(prev *sample, secs float64) view { func (d *direction) view(prev *sample, secs float64) view {
@@ -369,8 +413,9 @@ func (d *direction) displayView(t time.Time) view {
d.win.push(rateSample{t, now.txFrames, now.txBytes, now.rxFrames, now.rxBytes}) d.win.push(rateSample{t, now.txFrames, now.txBytes, now.rxFrames, now.rxBytes})
v := d.counters(now) v := d.counters(now)
v.txFrames = d.heldFrames.get(t, v.txFrames) v.window = d.recent
v.txSent = d.heldSent.get(t, v.txSent) v.rxFrames = d.heldFrames.get(t, v.rxFrames)
v.rxGot = d.heldSent.get(t, v.rxGot)
o, n, ok := d.win.span() o, n, ok := d.win.span()
if !ok { if !ok {
@@ -382,10 +427,11 @@ func (d *direction) displayView(t time.Time) view {
} }
txF := n.txFrames - o.txFrames txF := n.txFrames - o.txFrames
rxF := n.rxFrames - o.rxFrames rxF := n.rxFrames - o.rxFrames
d.est.txPPS.update(float64(txF) / secs) full := d.win.filled
d.est.rxPPS.update(float64(rxF) / secs) d.est.txPPS.update(float64(txF)/secs, full)
d.est.txGbps.update(gbps(n.txBytes-o.txBytes, txF, secs)) d.est.rxPPS.update(float64(rxF)/secs, full)
d.est.rxGbps.update(gbps(n.rxBytes-o.rxBytes, rxF, secs)) d.est.txGbps.update(gbps(n.txBytes-o.txBytes, txF, secs), full)
d.est.rxGbps.update(gbps(n.rxBytes-o.rxBytes, rxF, secs), full)
v.txPPS = d.est.txPPS.value() v.txPPS = d.est.txPPS.value()
v.rxPPS = d.est.rxPPS.value() v.rxPPS = d.est.rxPPS.value()
@@ -402,24 +448,64 @@ func (d *direction) row(elapsed time.Duration, v view, target float64, length st
rateCell(v.txGbps, target), rateCell(v.txGbps, target),
commas(uint64(v.rxPPS)), commas(uint64(v.rxPPS)),
rateCell(v.rxGbps, target), rateCell(v.rxGbps, target),
statusCell(v.lost), statusCell(v.since.lost),
statusCell(v.late), statusCell(v.since.late),
statusCell(v.crc), statusCell(v.since.crc),
statusCell(v.badMagic), statusCell(v.since.badMagic),
statusCell(v.kdrop), statusCell(v.since.kdrop),
statusCell(v.link), statusCell(v.since.link),
statusCell(v.errors), statusCell(v.since.total()),
paint(v.cable.minText(), cCyan), paint(v.cable.minText(), cCyan),
paint(length, cCyan), paint(length, cCyan),
} }
} }
// Sampled once a second, since these are sysfs reads; the display uses whatever // Sampled once a second, since these are sysfs reads. The recent errors roll
// the last sample left behind. // here rather than over the rate window because the nic counters only move at
// this rate, and a shorter span would alias them into a flicker.
func (d *direction) sampleNIC() { func (d *direction) sampleNIC() {
d.accumulateNIC()
now := d.snapshot()
b := d.recentBase
d.recent = errs{
lost: now.lost - b.lost,
late: now.late - b.late,
crc: now.crcErr - b.crcErr,
badMagic: now.badMagic - b.badMagic,
badLen: now.badLen - b.badLen,
kdrop: d.drops - d.recentDrops,
link: d.nicNow - d.recentNic +
(now.txErrs - b.txErrs) + (now.rxErrs - b.rxErrs),
}
d.recentBase, d.recentDrops, d.recentNic = now, d.drops, d.nicNow
}
func (d *direction) readNICTotal() uint64 {
tx := readNIC(d.tx.name) tx := readNIC(d.tx.name)
rx := readNIC(d.rx.name) rx := readNIC(d.rx.name)
d.nicNow = tx.tx + rx.rx + tx.carrierDown return tx.tx + rx.rx + tx.carrierDown
}
// Sysfs nic counters run from boot and restart from zero whenever the driver
// resets its statistics, so only their forward motion is accumulated. Taking
// raw differences instead charges a boot's worth of errors to the first sample
// and turns a reset into a near-2^64 underflow.
func (d *direction) accumulateNIC() {
raw := d.readNICTotal()
if raw > d.nicRaw {
d.nicNow += raw - d.nicRaw
}
d.nicRaw = raw
}
// Whatever the interfaces counted before now is not ours, and no interval has
// elapsed yet, so every baseline starts here and nothing is reported until the
// first one completes.
func (d *direction) primeCounters() {
d.nicRaw = d.readNICTotal()
d.reset()
d.recentBase, d.recentDrops, d.recentNic = d.snapshot(), d.drops, d.nicNow
} }
func buildDirection(label string, tx, rx endpoint, sizes []int, cfg config) (*direction, error) { func buildDirection(label string, tx, rx endpoint, sizes []int, cfg config) (*direction, error) {
@@ -471,9 +557,6 @@ func buildDirection(label string, tx, rx endpoint, sizes []int, cfg config) (*di
} }
d.probeRxFD = fd d.probeRxFD = fd
// Whatever the interfaces have counted before now is not ours.
d.sampleNIC()
d.nicBase = d.nicNow
return d, nil return d, nil
} }
@@ -539,7 +622,6 @@ type config struct {
streams int streams int
batch int batch int
probeEther uint16 probeEther uint16
zeroNS float64
nsPerM float64 nsPerM float64
} }
@@ -550,13 +632,13 @@ func main() {
sizesArg = flag.String("sizes", "64,128,256,512,1024,1280,1514", "frame sizes in bytes, excluding FCS, cycled per packet") sizesArg = flag.String("sizes", "64,128,256,512,1024,1280,1514", "frame sizes in bytes, excluding FCS, cycled per packet")
streams = flag.Int("streams", 7, "independent streams per direction, capped by rx rings; each gets its own ethertype, steered by a flow rule to its own rx queue") streams = flag.Int("streams", 7, "independent streams per direction, capped by rx rings; each gets its own ethertype, steered by a flow rule to its own rx queue")
batch = flag.Int("batch", 64, "frames per sendmmsg/recvmmsg call") batch = flag.Int("batch", 64, "frames per sendmmsg/recvmmsg call")
zeroNS = flag.Float64("zero-ns", 2163.77, "mean of both directions at zero cable length; belongs to the media adapters, recalibrate when they change")
nsPerM = flag.Float64("ns-per-m", 5.4545, "mean of both directions, per metre of cable") nsPerM = flag.Float64("ns-per-m", 5.2, "mean of both directions, per metre of cable")
) )
flag.Parse() flag.Parse()
if err := run(*aName, *bName, *sizesArg, if err := run(*aName, *bName, *sizesArg,
*streams, *batch, *zeroNS, *nsPerM); err != nil { *streams, *batch, *nsPerM); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1) os.Exit(1)
} }
@@ -574,7 +656,7 @@ const (
) )
func run(aName, bName, sizesArg string, func run(aName, bName, sizesArg string,
nStreams, batch int, zeroNS, nsPerM float64) error { nStreams, batch int, nsPerM float64) error {
if aName == "" || bName == "" { if aName == "" || bName == "" {
return fmt.Errorf("both -a and -b are required") return fmt.Errorf("both -a and -b are required")
@@ -629,7 +711,6 @@ func run(aName, bName, sizesArg string,
streams: nStreams, streams: nStreams,
batch: batch, batch: batch,
probeEther: uint16(etherBase + nStreams), probeEther: uint16(etherBase + nStreams),
zeroNS: zeroNS,
nsPerM: nsPerM, nsPerM: nsPerM,
} }
@@ -674,7 +755,7 @@ func run(aName, bName, sizesArg string,
nStreams, ethertypes[0], ethertypes[len(ethertypes)-1])}, nStreams, ethertypes[0], ethertypes[len(ethertypes)-1])},
{"probe", fmt.Sprintf("ethertype 0x%04x every %s", cfg.probeEther, probeInterval)}, {"probe", fmt.Sprintf("ethertype 0x%04x every %s", cfg.probeEther, probeInterval)},
{"batch", fmt.Sprintf("%d frames per syscall", batch)}, {"batch", fmt.Sprintf("%d frames per syscall", batch)},
{"calibration", fmt.Sprintf("%g ns at zero length, %g ns/m", zeroNS, nsPerM)}, {"calibration", fmt.Sprintf("%g ns/m, zero taken from the shortest delay seen so far", nsPerM)},
{"buffers", fmt.Sprintf("sndbuf %s, rcvbuf %s", {"buffers", fmt.Sprintf("sndbuf %s, rcvbuf %s",
humanBytes(uint64(sockBufSize(dirs[0].txFDs[0], unix.SO_SNDBUF))), humanBytes(uint64(sockBufSize(dirs[0].txFDs[0], unix.SO_SNDBUF))),
humanBytes(uint64(sockBufSize(dirs[0].rxFDs[0], unix.SO_RCVBUF))))}, humanBytes(uint64(sockBufSize(dirs[0].rxFDs[0], unix.SO_RCVBUF))))},
@@ -705,11 +786,15 @@ func run(aName, bName, sizesArg string,
} }
defer disp.close() defer disp.close()
touch, err := watchTouch(disp.fb.w, disp.fb.h) touch, err := watchTouch(disp.fb.pw, disp.fb.ph)
if err != nil { if err != nil {
return fmt.Errorf("touchscreen: %w", err) return fmt.Errorf("touchscreen: %w", err)
} }
for _, d := range dirs {
d.primeCounters()
}
start := time.Now() start := time.Now()
close(startTx) close(startTx)
tick := time.NewTicker(reportInterval) tick := time.NewTicker(reportInterval)
@@ -735,13 +820,20 @@ func run(aName, bName, sizesArg string,
case <-space: case <-space:
start = resetAll(dirs, stats) start = resetAll(dirs, stats)
case now := <-frame.C: case now := <-frame.C:
if x, y, down := touch.get(); disp.holdReset(x, y, down, now) { px, py, down := touch.get()
x, y := disp.fb.fromPanel(px, py)
if disp.holdReset(x, y, down, now) {
start = resetAll(dirs, stats) start = resetAll(dirs, stats)
} }
for i, d := range dirs { for i, d := range dirs {
views[i] = d.displayView(now) views[i] = d.displayView(now)
} }
disp.render(dirs, views, now.Sub(start), target, cfg.cableText(views)) cable := "-"
if m, ok := cfg.cableMetres(views); ok {
cable = fmt.Sprintf("%.1f m", m)
}
disp.render(totalView(views), now.Sub(start),
target*float64(len(dirs)), cable)
case now := <-tick.C: case now := <-tick.C:
secs := now.Sub(last).Seconds() secs := now.Sub(last).Seconds()
last = now last = now
+12 -17
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"fmt"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -34,6 +33,7 @@ type cableStats struct {
mu sync.Mutex mu sync.Mutex
min int64 min int64
samples uint64 samples uint64
floor int64
txPend map[uint64]int64 txPend map[uint64]int64
rxPend map[uint64]int64 rxPend map[uint64]int64
@@ -41,11 +41,12 @@ type cableStats struct {
type cableView struct { type cableView struct {
min int64 min int64
samples uint64 floor int64
ok bool
} }
func (v cableView) minText() string { func (v cableView) minText() string {
if v.samples == 0 { if !v.ok {
return "-" return "-"
} }
return commasInt(v.min) return commasInt(v.min)
@@ -57,23 +58,14 @@ func (c config) cableMetres(views []view) (float64, bool) {
if len(views) == 0 { if len(views) == 0 {
return 0, false return 0, false
} }
var sum float64 var excess float64
for _, v := range views { for _, v := range views {
if v.cable.samples == 0 { if !v.cable.ok {
return 0, false return 0, false
} }
sum += float64(v.cable.min) excess += float64(v.cable.min - v.cable.floor)
} }
mean := sum / float64(len(views)) return excess / float64(len(views)) / c.nsPerM, true
return (mean - c.zeroNS) / c.nsPerM, true
}
func (c config) cableText(views []view) string {
m, ok := c.cableMetres(views)
if !ok {
return ""
}
return fmt.Sprintf("cable %.1f m", m)
} }
func newCableStats() *cableStats { func newCableStats() *cableStats {
@@ -114,13 +106,16 @@ func (c *cableStats) put(seq uint64, ts int64, tx bool) {
if c.samples == 0 || delta < c.min { if c.samples == 0 || delta < c.min {
c.min = delta c.min = delta
} }
if c.floor == 0 || delta < c.floor {
c.floor = delta
}
c.samples++ c.samples++
} }
func (c *cableStats) view() cableView { func (c *cableStats) view() cableView {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return cableView{c.min, c.samples} return cableView{c.min, c.floor, c.samples > 0}
} }
func (c *cableStats) reset() { func (c *cableStats) reset() {
+10 -6
View File
@@ -199,14 +199,18 @@ func renderBox(title string, headers []string, rights []bool, rows [][]string) s
} }
var b strings.Builder var b strings.Builder
if title != "" { writeln := func(s string) {
b.WriteString(paint(title, cBold+cCyan) + "\n") b.WriteString(s)
b.WriteByte('\n')
} }
b.WriteString(line("┌", "┬", "┐") + "\n") if title != "" {
b.WriteString(rowText(headers, true) + "\n") writeln(paint(title, cBold+cCyan))
b.WriteString(line("├", "┼", "┤") + "\n") }
writeln(line("┌", "┬", "┐"))
writeln(rowText(headers, true))
writeln(line("├", "┼", "┤"))
for _, r := range rows { for _, r := range rows {
b.WriteString(rowText(r, false) + "\n") writeln(rowText(r, false))
} }
b.WriteString(line("└", "┴", "┘")) b.WriteString(line("└", "┴", "┘"))
return b.String() return b.String()
+94 -139
View File
@@ -8,39 +8,27 @@ import (
var ( var (
uiBg = rgb{0x12, 0x14, 0x18} uiBg = rgb{0x12, 0x14, 0x18}
uiPanel = rgb{0x1c, 0x20, 0x26} uiOKFill = rgb{0x18, 0x42, 0x26}
uiRule = rgb{0x2e, 0x34, 0x3c} uiOKEdge = rgb{0x3c, 0xe0, 0x70}
uiErrFil = rgb{0x54, 0x18, 0x1c}
uiErrEdg = rgb{0xff, 0x46, 0x46}
uiButton = rgb{0x25, 0x2b, 0x33} uiButton = rgb{0x25, 0x2b, 0x33}
uiFg = rgb{0xe6, 0xe8, 0xea} uiFg = rgb{0xe6, 0xe8, 0xea}
uiDim = rgb{0x7a, 0x82, 0x8c} uiDim = rgb{0x9a, 0xa2, 0xac}
uiCyan = rgb{0x5c, 0xc8, 0xe0} uiCyan = rgb{0x5c, 0xc8, 0xe0}
uiGreen = rgb{0x4c, 0xc2, 0x6a} uiGreen = rgb{0x6c, 0xdc, 0x86}
uiRed = rgb{0xe0, 0x4b, 0x4b} uiRed = rgb{0xf0, 0x6b, 0x6b}
uiYellow = rgb{0xe0, 0xb0, 0x40} uiYellow = rgb{0xe0, 0xb0, 0x40}
) )
// Everything tabular sits on one monospace cell grid and every number is
// right-aligned to a column end, so columns line up by construction.
const ( const (
uiMargin = 16 uiMargin = 16
uiPad = 14
uiBorder = 12
rowGap = 5
blockGap = 16
cellDirA = 0 btnW = 240
cellArrow = 2
cellDirB = 4
colTxGbEnd = 14
colTxPPSEnd = 27
colRxGbEnd = 37
colRxPPSEnd = 50
colFramesEnd = 18
colDataEnd = 27
colLostEnd = 38
colLateEnd = 46
colCRCEnd = 53
colKdropEnd = 62
btnW = 200
btnH = 64 btnH = 64
holdDuration = time.Second holdDuration = time.Second
) )
@@ -58,8 +46,11 @@ type display struct {
huge *textFace huge *textFace
grid *textFace grid *textFace
gridB *textFace gridB *textFace
small *textFace
nowPanel rect
sincePanel rect
nowH int
sinceH int
resetBtn rect resetBtn rect
holdStart time.Time holdStart time.Time
holdFrac float64 holdFrac float64
@@ -77,10 +68,9 @@ func newDisplay() (*display, error) {
bold bool bold bool
size float64 size float64
}{ }{
{&d.huge, true, 72}, {&d.huge, true, 60},
{&d.grid, false, 24}, {&d.grid, false, 22},
{&d.gridB, true, 24}, {&d.gridB, true, 22},
{&d.small, false, 18},
} { } {
face, err := loadFace(spec.bold, spec.size) face, err := loadFace(spec.bold, spec.size)
if err != nil { if err != nil {
@@ -94,9 +84,22 @@ func newDisplay() (*display, error) {
return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d", return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d",
d.grid.cellW, d.gridB.cellW) d.grid.cellW, d.gridB.cellW)
} }
// Guessed heights collide on a screen this small, so the split follows what
// the loaded faces actually measure.
gridLine := d.grid.cellH + rowGap
errH := len(errRows) * gridLine
d.nowH = d.huge.cellH + rowGap + gridLine + blockGap + errH
d.sinceH = 4*gridLine + blockGap + errH + blockGap + btnH
inner := fb.w - 2*uiMargin
chrome := 2 * (uiBorder + uiPad)
avail := fb.h - 2*uiMargin - blockGap
h1 := (avail-2*chrome)*d.nowH/(d.nowH+d.sinceH) + chrome
d.nowPanel = rect{uiMargin, uiMargin, inner, h1}
d.sincePanel = rect{uiMargin, uiMargin + h1 + blockGap, inner, avail - h1}
d.resetBtn = rect{ d.resetBtn = rect{
x: fb.w - uiMargin - btnW, x: d.sincePanel.x + (inner-btnW)/2,
y: fb.h - uiMargin - btnH, y: d.sincePanel.y + d.sincePanel.h - uiBorder - uiPad - btnH,
w: btnW, w: btnW,
h: btnH, h: btnH,
} }
@@ -163,28 +166,59 @@ func (d *display) close() {
d.fb.close() d.fb.close()
} }
func (d *display) cellX(c int) int {
return uiMargin + c*d.grid.cellW
}
func (d *display) at(c, y int, s string, col rgb) {
d.grid.draw(d.fb, d.cellX(c), y, s, col)
}
func (d *display) atBold(c, y int, s string, col rgb) {
d.gridB.draw(d.fb, d.cellX(c), y, s, col)
}
func (d *display) rightAt(cEnd, y int, s string, col rgb) {
d.grid.draw(d.fb, d.cellX(cEnd)-len([]rune(s))*d.grid.cellW, y, s, col)
}
func (d *display) right(f *textFace, xEnd, y int, s string, col rgb) { func (d *display) right(f *textFace, xEnd, y int, s string, col rgb) {
f.draw(d.fb, xEnd-len([]rune(s))*f.cellW, y, s, col) f.draw(d.fb, xEnd-len([]rune(s))*f.cellW, y, s, col)
} }
func (d *display) center(f *textFace, y int, s string, col rgb) { func (d *display) row(f *textFace, x, w, y int, label, value string, col rgb) int {
f.draw(d.fb, (d.fb.w-len([]rune(s))*f.cellW)/2, y, s, col) f.draw(d.fb, x, y, label, uiDim)
d.right(f, x+w, y, value, col)
return y + f.cellH + rowGap
}
func (d *display) rateRow(x, w, y int, label string, gb, target float64) int {
d.gridB.draw(d.fb, x, y+(d.huge.cellH-d.gridB.cellH)/2, label, uiDim)
d.right(d.huge, x+w, y, fmt.Sprintf("%.2f Gb/s", gb), rateColor(gb, target))
return y + d.huge.cellH + rowGap
}
var errRows = []struct {
label string
get func(errs) uint64
}{
{"lost", func(e errs) uint64 { return e.lost }},
{"late", func(e errs) uint64 { return e.late }},
{"crc", func(e errs) uint64 { return e.crc }},
{"bad magic", func(e errs) uint64 { return e.badMagic }},
{"bad length", func(e errs) uint64 { return e.badLen }},
{"kernel drops", func(e errs) uint64 { return e.kdrop }},
{"link", func(e errs) uint64 { return e.link }},
}
func (d *display) errBlock(x, w, y int, e errs) int {
for _, r := range errRows {
n := r.get(e)
y = d.row(d.grid, x, w, y, r.label, commas(n), errColor(n))
}
return y
}
func (d *display) panel(p rect, e errs, contentH int) (int, int, int) {
fill, edge := uiOKFill, uiOKEdge
if e.total() > 0 {
fill, edge = uiErrFil, uiErrEdg
}
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
x, w := p.x+inset, p.w-2*inset
y := p.y + inset
if slack := (p.y + p.h - inset) - y - contentH; slack > 0 {
y += slack / 2
}
return x, w, y
} }
// Rendered rates are quantised so the text only changes when the value moves // Rendered rates are quantised so the text only changes when the value moves
@@ -216,101 +250,22 @@ func rateColor(gb, target float64) rgb {
} }
} }
func (d *display) render(dirs []*direction, views []view, elapsed time.Duration, target float64, cable string) { func (d *display) render(v view, elapsed time.Duration, target float64, cable string) {
fb := d.fb fb := d.fb
fb.fill(uiBg) fb.fill(uiBg)
d.small.draw(fb, uiMargin, 10, "cabletest", uiCyan) x, w, y := d.panel(d.nowPanel, v.window, d.nowH)
d.small.draw(fb, uiMargin+11*d.small.cellW, 10, y = d.rateRow(x, w, y, "RX", v.rxGbps, target)
fmt.Sprintf("%s %s %s %s", dirs[0].tx.tag, dirs[0].tx.name, y = d.row(d.grid, x, w, y, "packets/s", commas(roundPPS(v.rxPPS)), uiFg)
dirs[0].rx.tag, dirs[0].rx.name), uiDim) d.errBlock(x, w, y+blockGap, v.window)
d.right(d.small, fb.w-uiMargin, 10, uptime(elapsed), uiDim)
var total uint64 x, w, y = d.panel(d.sincePanel, v.since, d.sinceH)
for _, v := range views { y = d.row(d.grid, x, w, y, "uptime", uptime(elapsed), uiFg)
total += v.errors y = d.row(d.grid, x, w, y, "frames", commas(v.rxFrames), uiFg)
} y = d.row(d.grid, x, w, y, "data", humanBytes(v.rxGot), uiFg)
y = d.row(d.grid, x, w, y, "cable", cable, uiCyan)
bandY, bandH := 40, 150 d.errBlock(x, w, y+blockGap, v.since)
fb.rect(0, bandY, fb.w, bandH, uiPanel)
fb.rect(0, bandY, 8, bandH, errColor(total))
word, wc := "NO ERRORS", uiGreen
if total > 0 {
word, wc = fmt.Sprintf("%s ERRORS", commas(total)), uiRed
}
d.center(d.huge, bandY+(bandH-d.huge.cellH)/2, word, wc)
// Two tight blocks rather than rows spread over the whole panel, centred in
// what is left below the band.
lineH := d.grid.cellH + 4
sectionH := d.small.cellH + 10 + (1+len(dirs))*lineH
gap := 30
avail := fb.h - (bandY + bandH) - btnH - 2*uiMargin
y := bandY + bandH + 8 + (avail-2*sectionH-gap)/2
y = d.section(y, "RATE", "")
d.rightAt(colTxGbEnd, y, "TX Gb/s", uiDim)
d.rightAt(colTxPPSEnd, y, "TX pps", uiDim)
d.rightAt(colRxGbEnd, y, "RX Gb/s", uiDim)
d.rightAt(colRxPPSEnd, y, "RX pps", uiDim)
y += lineH
for i, dir := range dirs {
d.dirTag(dir, y)
v := views[i]
d.rightAt(colTxGbEnd, y, fmt.Sprintf("%.2f", v.txGbps), rateColor(v.txGbps, target))
d.rightAt(colTxPPSEnd, y, commas(roundPPS(v.txPPS)), uiFg)
d.rightAt(colRxGbEnd, y, fmt.Sprintf("%.2f", v.rxGbps), rateColor(v.rxGbps, target))
d.rightAt(colRxPPSEnd, y, commas(roundPPS(v.rxPPS)), uiFg)
y += lineH
}
y += gap
y = d.section(y, "OVERALL", cable)
d.rightAt(colFramesEnd, y, "frames", uiDim)
d.rightAt(colDataEnd, y, "data", uiDim)
d.rightAt(colLostEnd, y, "lost", uiDim)
d.rightAt(colLateEnd, y, "late", uiDim)
d.rightAt(colCRCEnd, y, "crc", uiDim)
d.rightAt(colKdropEnd, y, "kdrop", uiDim)
y += lineH
for i, dir := range dirs {
d.dirTag(dir, y)
v := views[i]
d.rightAt(colFramesEnd, y, commas(v.txFrames), uiFg)
d.rightAt(colDataEnd, y, humanBytes(v.txSent), uiFg)
d.rightAt(colLostEnd, y, commas(v.lost), errColor(v.lost))
d.rightAt(colLateEnd, y, commas(v.late), errColor(v.late))
d.rightAt(colCRCEnd, y, commas(v.crc), errColor(v.crc))
d.rightAt(colKdropEnd, y, commas(v.kdrop), errColor(v.kdrop))
y += lineH
}
d.drawResetButton() d.drawResetButton()
fb.flush() fb.flush()
} }
func (d *display) section(y int, title, right string) int {
d.small.draw(d.fb, uiMargin, y, title, uiFg)
if right != "" {
d.right(d.small, d.cellX(colKdropEnd), y, right, uiCyan)
}
ruleY := y + d.small.cellH + 3
d.fb.rect(uiMargin, ruleY, d.fb.w-2*uiMargin, 1, uiRule)
return ruleY + 7
}
func (d *display) dirTag(dir *direction, y int) {
d.atBold(cellDirA, y, dir.tx.tag, uiCyan)
d.arrow(d.cellX(cellArrow), y+d.grid.cellH/2, uiDim)
d.atBold(cellDirB, y, dir.rx.tag, uiCyan)
}
// Drawn because the font has no arrow glyph. Occupies exactly one cell so the
// grid is unaffected.
func (d *display) arrow(x, y int, c rgb) {
w := d.grid.cellW
d.fb.rect(x, y-1, w-5, 2, c)
for i := 0; i < 6; i++ {
d.fb.rect(x+w-6+i, y-5+i, 1, 11-2*i, c)
}
}