diff --git a/pkg/report/interactive/model.go b/pkg/report/interactive/model.go index 28b109d..4eeacdf 100644 --- a/pkg/report/interactive/model.go +++ b/pkg/report/interactive/model.go @@ -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 @@ -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")) @@ -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). @@ -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{ @@ -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() @@ -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 @@ -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. @@ -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 { @@ -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 @@ -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.