Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 200 additions & 74 deletions pkg/report/interactive/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ type (
trafficDone bool
done bool

startTime time.Time
totalDuration time.Duration
width int
startTime time.Time
trafficEndTime time.Time // set when trafficDoneMsg is received; freezes RPM calculation
totalDuration time.Duration
width int
height int

// trafficCancel is called when the user presses Ctrl-C inside the TUI so that
// the arbiter shutdown sequence is triggered without relying on SIGINT
Expand All @@ -69,12 +71,19 @@ const (
refreshInterval = time.Second
defaultWidth = 80
opNameWidth = 20
opColWidth = 20
barWidth = 30
callsLabelW = 8 // len("success:")
timingLabelW = 4 // len("avg:")
headerMinGap = 2
)

// Styles used throughout the TUI.
var (
brandStyle = lipgloss.NewStyle().
titleBlockStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("205")).
PaddingLeft(3).
PaddingRight(3).
Bold(true).
Foreground(lipgloss.Color("205"))

Expand All @@ -88,7 +97,7 @@ var (
Foreground(lipgloss.Color("238"))

timeRemainingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("39"))
Foreground(lipgloss.Color("238"))

modHeaderStyle = lipgloss.NewStyle().
Bold(true).
Expand All @@ -110,13 +119,36 @@ var (
Foreground(lipgloss.Color("238"))

doneStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("214"))
Foreground(lipgloss.Color("240"))

opNameStyle = lipgloss.NewStyle().
Bold(true)

colHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("39"))

rateConfigStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))

modBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("39")).
PaddingLeft(1).
PaddingRight(1)
)

// statusBoxStyle returns a RoundedBorder box style whose border is coloured
// according to the current test state. Content styling is applied separately
// so the label and value can use different weights.
func statusBoxStyle(color lipgloss.Color) lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(color).
PaddingLeft(1).
PaddingRight(1)
}

// newModel creates a model pre-populated with module and operation metadata.
func newModel(metadata module.Metadata, d time.Duration, stopFn func()) *model {
return &model{
Expand Down Expand Up @@ -151,6 +183,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height

case tickMsg:
return m, tickCmd()
Expand All @@ -163,6 +196,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case trafficDoneMsg:
m.trafficDone = true
m.trafficEndTime = time.Now()

case doneMsg:
m.done = true
Expand Down Expand Up @@ -215,62 +249,95 @@ func (m *model) View() string {
sb.WriteString("\n\n")

for _, mod := range m.metadata {
sb.WriteString(modHeaderStyle.Render(mod.Name()))
modInnerW := w - 4 // border(2) + padding(2) consumed by modBoxStyle
sb.WriteString(m.renderModule(mod, modInnerW))
sb.WriteString("\n")
}

innerW := w - 4
twoCol := false
if halfInnerW := (w - 10) / 2; halfInnerW >= 30 {
innerW = halfInnerW
twoCol = true
}

ops := mod.Ops()
for i := 0; i < len(ops); {
box1 := m.renderOp(mod.Name(), ops[i], innerW)
i++
if twoCol && i < len(ops) {
box2 := m.renderOp(mod.Name(), ops[i], innerW)
i++
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, box1, " ", box2))
} else {
sb.WriteString(box1)
footer := m.renderFooter()
if footer != "" {
if m.height > 0 {
contentLines := strings.Count(sb.String(), "\n") + 1
footerLines := strings.Count(footer, "\n") + 1
if padding := m.height - contentLines - footerLines; padding > 0 {
sb.WriteString(strings.Repeat("\n", padding))
}
sb.WriteString("\n")
}
sb.WriteString(footer)
sb.WriteString("\n")
}

// err -> done -> traffic done
return sb.String()
}

// renderFooter returns the status message shown at the bottom of the screen.
func (m *model) renderFooter() string {
switch {
case m.errMsg != "":
sb.WriteString(doneStyle.Render("Error: " + m.errMsg))
sb.WriteString("\n")
return doneStyle.Render("Error: " + m.errMsg)
case m.done:
sb.WriteString(doneStyle.Render("Test complete! Press CTRL-C to exit."))
sb.WriteString("\n")
return doneStyle.Render("Test complete! Press CTRL-C to exit.")
case m.trafficDone:
sb.WriteString(doneStyle.Render("Ramping down..."))
sb.WriteString("\n")
return doneStyle.Render("Ramping down...")
default:
return ""
}

return sb.String()
}

// renderHeader renders the top bar: "arbiter" brand on the left, progress bar
// and countdown clock filling the remaining width.
// renderHeader renders the top bar: title block on the left, a state-coloured
// status box next, then the progress bar with countdown stacked below it
// pushed to roughly the middle of the remaining space. The bar width is at
// least 50% of the space available after the title and status boxes.
func (m *model) renderHeader(w int) string {
brand := brandStyle.Render("arbiter")
suffix := " " + timeRemainingStyle.Render(formatDuration(m.timeRemaining())+" remaining")
titleBlock := titleBlockStyle.Render("arbiter")

brandW := lipgloss.Width(brand)
suffixW := lipgloss.Width(suffix)
barW := w - brandW - suffixW - 1 // 1 space between brand and bar
if barW < 1 {
barW = 1
var statusColor lipgloss.Color
var statusText string
switch {
case m.errMsg != "":
statusColor = lipgloss.Color("196")
statusText = "ERROR"
case m.done:
statusColor = lipgloss.Color("214")
statusText = "DONE"
default:
statusColor = lipgloss.Color("42")
statusText = "RUNNING"
}
statusLabel := lipgloss.NewStyle().Foreground(statusColor).Render("test status")
statusValue := lipgloss.NewStyle().Bold(true).Foreground(statusColor).Render(statusText)
statusBox := statusBoxStyle(statusColor).Render(statusLabel + " " + statusValue)

titleW := lipgloss.Width(titleBlock)
statusW := lipgloss.Width(statusBox)

// Bar width: at least 50% of the space remaining after title + status boxes.
rightAvail := w - titleW - headerMinGap - statusW
barW := rightAvail / 2
if barW < barWidth {
barW = barWidth
}

return brand + " " + m.renderProgressBar(barW) + suffix
bar := m.renderProgressBar(barW)
timeRaw := timeRemainingStyle.Render(formatDuration(m.timeRemaining()) + " remaining")
centeredTime := lipgloss.NewStyle().Width(barW).Align(lipgloss.Center).Render(timeRaw)
rightSection := bar + "\n" + centeredTime
rightW := lipgloss.Width(rightSection)

// Spacer pushes the bar to the midpoint of whatever space is left.
available := w - titleW - headerMinGap - statusW - rightW
spacer := available / 2
if spacer < headerMinGap {
spacer = headerMinGap
}

return lipgloss.JoinHorizontal(lipgloss.Center,
titleBlock,
strings.Repeat(" ", headerMinGap),
statusBox,
strings.Repeat(" ", spacer),
rightSection,
)
}

// renderProgressBar renders a filled/empty Unicode block progress bar.
Expand Down Expand Up @@ -314,6 +381,38 @@ func (m *model) timeRemaining() time.Duration {
return rem
}

// renderModule renders a full module section — header line plus all operation
// boxes — wrapped in a rounded border box. contentW is the inner content width
// (the box border and padding are added on top).
func (m *model) renderModule(mod *module.Meta, contentW int) string {
var sb strings.Builder
sb.WriteString(modHeaderStyle.Render("Module: " + mod.Name()))
sb.WriteString("\n\n")

opInnerW := contentW - 4 // border(2) + padding(2) consumed by opBoxStyle
twoCol := false
if halfOpInnerW := (contentW - 10) / 2; halfOpInnerW >= 30 {
opInnerW = halfOpInnerW
twoCol = true
}

ops := mod.Ops()
for i := 0; i < len(ops); {
box1 := m.renderOp(mod.Name(), ops[i], opInnerW)
i++
if twoCol && i < len(ops) {
box2 := m.renderOp(mod.Name(), ops[i], opInnerW)
i++
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, box1, " ", box2))
} else {
sb.WriteString(box1)
}
sb.WriteString("\n")
}

return modBoxStyle.Width(contentW).Render(strings.TrimSuffix(sb.String(), "\n"))
}

// renderOp renders a single operation's statistics box. Disabled operations
// are rendered with a greyed-out border and [DISABLED] label.
func (m *model) renderOp(modName string, op *module.Op, innerW int) string {
Expand All @@ -329,33 +428,58 @@ func (m *model) renderOp(modName string, op *module.Op, innerW int) string {
executions, nok, okCount, rpm uint
avgDur, minDur, maxDur time.Duration
)

elapsed := time.Since(m.startTime)
if m.trafficDone {
elapsed = m.trafficEndTime.Sub(m.startTime)
}

if modStats, ok := m.stats[modName]; ok {
stats := modStats[op.Name]
executions = stats.executions
nok = stats.nok
okCount = stats.ok
rpm = stats.observedRPM(m.startTime)
if executions > 0 && stats.totalDuration > 0 {
//nolint:gosec // no risk of overflow since the total duration is the sum
avgDur = stats.totalDuration / time.Duration(executions)
minDur = stats.minDuration
maxDur = stats.maxDuration
if stats, opOK := modStats[op.Name]; opOK {
executions = stats.executions
nok = stats.nok
okCount = stats.ok
rpm = stats.observedRPM(elapsed)
if executions > 0 && stats.totalDuration > 0 {
//nolint:gosec // no risk of overflow since the total duration is the sum
avgDur = stats.totalDuration / time.Duration(executions)
minDur = stats.minDuration
maxDur = stats.maxDuration
}
}
}

line1 := fmt.Sprintf("%-*s %s",
opColWidth, fmt.Sprintf("rate: %d/min", op.Rate),
fmt.Sprintf("actual: %d/min", rpm))
line2 := fmt.Sprintf("%-*s %-*s %s",
opColWidth, fmt.Sprintf("calls: %d", executions),
opColWidth, fmt.Sprintf("failed: %d", nok),
fmt.Sprintf("success: %s", successStr(executions, okCount)))
line3 := fmt.Sprintf("%-*s %-*s %s",
opColWidth, fmt.Sprintf("avg %s", formatOpDuration(avgDur)),
opColWidth, fmt.Sprintf("min %s", formatOpDuration(minDur)),
fmt.Sprintf("max %s", formatOpDuration(maxDur)))

return opBoxStyle.Width(innerW).Render(opNameStyle.Render(op.Name) + "\n" + line1 + "\n" + line2 + "\n" + line3)
// Three side-by-side columns: Rate | Calls | Timing
colW := (innerW - 2) / 3 // 2 single-char gaps between 3 columns
if colW < 8 {
colW = 8
}
colStyle := lipgloss.NewStyle().Width(colW)

// Rate: configured rate in the header; "Rate" is bold-blue, the value is plain white.
rateCol := colHeaderStyle.Render("Rate") + rateConfigStyle.Render(fmt.Sprintf(" (%d/min)", op.Rate)) + "\n" +
fmt.Sprintf("actual: %d/min", rpm)

// Calls: labels padded to callsLabelW so values align.
callsCol := colHeaderStyle.Render("Calls") + "\n" +
fmt.Sprintf("%-*s %d", callsLabelW, "calls:", executions) + "\n" +
fmt.Sprintf("%-*s %d", callsLabelW, "failed:", nok) + "\n" +
fmt.Sprintf("%-*s %s", callsLabelW, "success:", successStr(executions, okCount))

// Timing: labels padded to timingLabelW so values align; colon on each label.
timingCol := colHeaderStyle.Render("Timing") + "\n" +
fmt.Sprintf("%-*s %s", timingLabelW, "avg:", formatOpDuration(avgDur)) + "\n" +
fmt.Sprintf("%-*s %s", timingLabelW, "min:", formatOpDuration(minDur)) + "\n" +
fmt.Sprintf("%-*s %s", timingLabelW, "max:", formatOpDuration(maxDur))

columns := lipgloss.JoinHorizontal(lipgloss.Top,
colStyle.Render(rateCol), " ",
colStyle.Render(callsCol), " ",
colStyle.Render(timingCol))

return opBoxStyle.Width(innerW).Render(
opNameStyle.Render("Operation: "+op.Name) + "\n\n" + columns,
)
}

// successStr returns a formatted success percentage, or "—" when no calls
Expand All @@ -368,18 +492,20 @@ func successStr(executions, ok uint) string {
return fmt.Sprintf("%.1f%%", float64(ok)/float64(executions)*100)
}

// observedRPM returns the actual observed rate per minute since the first call.
func (s *opStats) observedRPM(startTime time.Time) uint {
// observedRPM returns the actual observed rate per minute. elapsed is the
// duration since the test started and may be frozen when traffic has stopped,
// preventing the rate from declining after the test ends.
func (s *opStats) observedRPM(elapsed time.Duration) uint {
if s.executions == 0 {
return 0
}

elapsed := time.Since(startTime).Minutes()
if elapsed < 0.001 {
minutes := elapsed.Minutes()
if minutes < 0.001 {
return 0
}

return uint(math.Round(float64(s.executions) / elapsed))
return uint(math.Round(float64(s.executions) / minutes))
}

// formatOpDuration formats an operation duration in a human-readable short form.
Expand Down