Skip to content
Open
Show file tree
Hide file tree
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
90 changes: 68 additions & 22 deletions cmd/spinloop/metrics_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package main
import (
"fmt"
"io"
"strings"
"time"

"github.com/spinloop-ai/spinloop/internal/metrics"
Expand Down Expand Up @@ -137,6 +138,11 @@ const barLineW = 40
// full row fits the tile exactly and the clip never takes the percentage.
const dashBarLineW = 25

// gaugeW is the gauge's draw width in the one-shot formats — the width
// renderGauge has always drawn at. The serve view draws its gauge half at
// serveGaugeW instead, beside the bar half of serveBarW.
const gaugeW = 25

// barGlyphs is the seven sub-full block elements the sparkline draws with,
// lightest to heaviest: a series' value maps to the one whose fill height is
// nearest. The set stops one grade short of the full block, so the tallest row
Expand Down Expand Up @@ -191,6 +197,15 @@ func poolMax(values []float64, width int) []float64 {
// gauge's 80/90 thresholds, and the trailing figure is the latest sample's
// percentage — the exact value the last glyph approximates.
func renderSparkline(w io.Writer, label string, samples []float64, width int) {
block, last := sparklineBlock(samples, width)
fmt.Fprintf(w, " %-9s %s %.0f%%\n", label, block, last)
}

// sparklineBlock returns the sparkline's drawing across width, one glyph per
// sample newest on the right, the window's leading columns blank while it
// still fills, and the final glyph in the state colour — the label and the
// trailing figure excluded — along with the latest sample the figure reports.
func sparklineBlock(samples []float64, width int) (string, float64) {
pooled := poolMax(samples, width)
last := pooled[len(pooled)-1]
colour := ansiGreen
Expand All @@ -199,26 +214,30 @@ func renderSparkline(w io.Writer, label string, samples []float64, width int) {
} else if last >= 80 {
colour = ansiYellow
}
fmt.Fprintf(w, " %-9s ", label)
for i := 0; i < width-len(pooled); i++ {
fmt.Fprint(w, " ")
}
var b strings.Builder
b.WriteString(strings.Repeat(" ", width-len(pooled)))
for i, v := range pooled {
if i == len(pooled)-1 {
fmt.Fprintf(w, "%s%c%s", colour, barGlyph(v), ansiReset)
b.WriteString(fmt.Sprintf("%s%c%s", colour, barGlyph(v), ansiReset))
} else {
fmt.Fprintf(w, "%c", barGlyph(v))
b.WriteRune(barGlyph(v))
}
}
fmt.Fprintf(w, " %.0f%%\n", last)
return b.String(), last
}

// renderGauge draws one resource series as a horizontal progress gauge: the
// filled portion in the state colour, the unfilled portion in light shade,
// the percentage in the terminal's default colour. It draws the current
// reading only — it carries no history.
func renderGauge(w io.Writer, label string, pct float64) {
const width = 25
func renderGauge(w io.Writer, label string, pct float64, width int) {
fmt.Fprintf(w, " %-9s %s %.0f%%\n", label, gaugeBlock(pct, width), pct)
}

// gaugeBlock returns the gauge's drawing at width — the filled portion in
// the state colour, the rest in light shade — the label and the trailing
// figure excluded.
func gaugeBlock(pct float64, width int) string {
colour := ansiGreen
if pct > 90 {
colour = ansiRed
Expand All @@ -229,17 +248,7 @@ func renderGauge(w io.Writer, label string, pct float64) {
if filled > width {
filled = width
}
empty := width - filled
fmt.Fprintf(w, " %-9s ", label)
fmt.Fprintf(w, "%s", colour)
for i := 0; i < filled; i++ {
fmt.Fprint(w, "█")
}
fmt.Fprintf(w, "%s", ansiReset)
for i := 0; i < empty; i++ {
fmt.Fprint(w, "░")
}
fmt.Fprintf(w, " %.0f%%\n", pct)
return colour + strings.Repeat("█", filled) + ansiReset + strings.Repeat("░", width-filled)
}

// barSeries is one resource series the bar and gauge formats draw: the label
Expand Down Expand Up @@ -366,8 +375,45 @@ func renderStatBars(w io.Writer, cpu *metrics.CpuStat, mem *metrics.MemoryStat,
if len(s.history) > 0 {
renderSparkline(w, s.label, s.history, lineW)
} else {
renderGauge(w, s.label, *s.current)
renderGauge(w, s.label, *s.current, gaugeW)
}
}
}

// serveGaugeW and serveBarW are the two halves of the serve view's combined
// line: gauge and sparkline at these widths with the figure between them,
// one line per series, sized so the line fits the default 80-column window
// label and figure included.
const (
serveGaugeW = 20
serveBarW = 25
)

// renderStatCombined draws the resource series in the serve view's format:
// each series on one line — its gauge of the current reading, the figure,
// and its bar of the retained history — so "now" and "trend" sit together
// per resource instead of being a toggle. The figure is the current reading,
// falling back to the bar's latest sample where the reading carries no
// current one; a series with no history leaves its bar half blank and a
// series with no current reading its gauge half blank, so the lines align
// and the figure never draws a half's own number twice.
func renderStatCombined(w io.Writer, cpu *metrics.CpuStat, mem *metrics.MemoryStat, gpus []metrics.GpuStat, history []metrics.HistorySample) {
for _, s := range barSeriesList(cpu, mem, gpus, history) {
gaugeHalf := strings.Repeat(" ", serveGaugeW)
barHalf := strings.Repeat(" ", serveBarW)
figure := 0.0
if s.current != nil {
gaugeHalf = gaugeBlock(*s.current, serveGaugeW)
figure = *s.current
}
if len(s.history) > 0 {
var last float64
barHalf, last = sparklineBlock(s.history, serveBarW)
if s.current == nil {
figure = last
}
}
fmt.Fprintf(w, " %-9s %s %.0f%% %s\n", s.label, gaugeHalf, figure, barHalf)
}
}

Expand All @@ -376,7 +422,7 @@ func renderStatBars(w io.Writer, cpu *metrics.CpuStat, mem *metrics.MemoryStat,
func renderStatGauges(w io.Writer, cpu *metrics.CpuStat, mem *metrics.MemoryStat, gpus []metrics.GpuStat) {
for _, s := range barSeriesList(cpu, mem, gpus, nil) {
if s.current != nil {
renderGauge(w, s.label, *s.current)
renderGauge(w, s.label, *s.current, gaugeW)
}
}
}
Expand Down
137 changes: 135 additions & 2 deletions cmd/spinloop/metrics_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"
"testing"

"github.com/charmbracelet/lipgloss"
"github.com/spinloop-ai/spinloop/internal/metrics"
"github.com/spinloop-ai/spinloop/internal/remote"
)
Expand Down Expand Up @@ -110,14 +111,14 @@ func TestRenderSparklineColoursOnlyTheLastPoint(t *testing.T) {

func TestRenderGauge(t *testing.T) {
var b bytes.Buffer
renderGauge(&b, "CPU", 42)
renderGauge(&b, "CPU", 42, gaugeW)
want := " CPU " + ansiGreen + strings.Repeat("█", 10) + ansiReset + strings.Repeat("░", 15) + " 42%\n"
if got := b.String(); got != want {
t.Errorf("gauge = %q, want %q", got, want)
}
// A value beyond 100 fills the gauge rather than spilling past it.
b.Reset()
renderGauge(&b, "CPU", 150)
renderGauge(&b, "CPU", 150, gaugeW)
want = " CPU " + ansiRed + strings.Repeat("█", 25) + ansiReset + " 150%\n"
if got := b.String(); got != want {
t.Errorf("out-of-range gauge = %q, want %q", got, want)
Expand Down Expand Up @@ -307,6 +308,138 @@ func TestRenderStatGaugesIgnoresHistory(t *testing.T) {
}
}

// The serve view's format draws both halves together: each series as a gauge
// of its current reading with its retained history as a sparkline beneath,
// the gauge carrying the label and the sparkline a blank one, so the pair
// stacks in the label column.
func TestRenderStatCombined(t *testing.T) {
cpu := &metrics.CpuStat{Utilization: 42}
mem := &metrics.MemoryStat{Total: 1000, Used: 300}
gpus := []metrics.GpuStat{{Index: 0, Name: "H100", Utilization: 61, MemoryUsed: 80, MemoryTotal: 160}}
history := []metrics.HistorySample{
{Time: 1, CPU: ptrPct(10), Mem: ptrPct(20), GPUs: []metrics.HistoryGPU{{Index: 0, Util: 30, Mem: ptrPct(10)}}},
{Time: 2, CPU: ptrPct(95), Mem: ptrPct(30), GPUs: []metrics.HistoryGPU{{Index: 0, Util: 91, Mem: ptrPct(20)}}},
}
var b bytes.Buffer
renderStatCombined(&b, cpu, mem, gpus, history)
lines := strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n")
// One line per series, in the bar series' order.
if len(lines) != 4 {
t.Fatalf("drew %d lines, want 4: %q", len(lines), b.String())
}
for i, label := range []string{" CPU ", " RAM ", " GPU util ", " GPU mem "} {
if !strings.HasPrefix(lines[i], label) {
t.Errorf("line for %s: %q", label, lines[i])
}
}
// The line is label, gauge half, figure, bar half — the figure between
// the two halves, and it is the current reading, not the bar's last
// sample.
cpuBar, _ := sparklineBlock([]float64{10, 95}, serveBarW)
wantCPU := " CPU " + gaugeBlock(42, serveGaugeW) + " 42% " + cpuBar
if lines[0] != wantCPU {
t.Errorf("CPU line:\ngot %q\nwant %q", lines[0], wantCPU)
}
// Both halves take the state colour: the gauge over its whole fill, the
// sparkline on its last glyph only — and the sparkline stops one grade
// short of the full block, so the 95% sample draws the top of the seven
// sub-full glyphs.
if !strings.Contains(lines[0], ansiGreen+strings.Repeat("█", 8)+ansiReset+strings.Repeat("░", 12)) {
t.Errorf("CPU gauge: %q", lines[0])
}
if !strings.Contains(lines[0], ansiRed+"▇"+ansiReset) {
t.Errorf("CPU sparkline: %q", lines[0])
}
// Every line is label, gauge half, figure and bar half: the halves sit
// side by side, aligned across the series. 62 is label (12), gauge half,
// " 42%"-style figure (4, two digits in this data), a space, bar half.
for i, line := range lines {
if w := lipgloss.Width(line); w != 12+serveGaugeW+4+1+serveBarW {
t.Errorf("line %d is %d columns wide, want %d: %q", i, w, 12+serveGaugeW+4+1+serveBarW, line)
}
}
}

// A memory reading with no total reports 0, not a division by it, in either
// half of the combined format.
func TestRenderStatCombinedMemoryWithoutTotal(t *testing.T) {
var b bytes.Buffer
renderStatCombined(&b, nil, &metrics.MemoryStat{Total: 0, Used: 100}, nil, nil)
if !strings.Contains(b.String(), " 0%") || strings.Contains(b.String(), "NaN") {
t.Errorf("a memory reading with no total: %q", b.String())
}
}

// Without retained history each series carries its gauge alone, its bar
// half left blank.
func TestRenderStatCombinedWithoutHistory(t *testing.T) {
cpu := &metrics.CpuStat{Utilization: 42}
mem := &metrics.MemoryStat{Total: 1000, Used: 300}
gpus := []metrics.GpuStat{{Index: 0, Name: "H100", Utilization: 61, MemoryUsed: 80, MemoryTotal: 160}}
var b bytes.Buffer
renderStatCombined(&b, cpu, mem, gpus, nil)
lines := strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n")
blankBar := strings.Repeat(" ", serveBarW)
want := []string{
" CPU " + gaugeBlock(42, serveGaugeW) + " 42% " + blankBar,
" RAM " + gaugeBlock(30, serveGaugeW) + " 30% " + blankBar,
" GPU util " + gaugeBlock(61, serveGaugeW) + " 61% " + blankBar,
" GPU mem " + gaugeBlock(50, serveGaugeW) + " 50% " + blankBar,
}
if len(lines) != len(want) {
t.Fatalf("drew %d lines, want %d: %q", len(lines), len(want), b.String())
}
for i := range want {
if lines[i] != want[i] {
t.Errorf("line %d:\ngot %q\nwant %q", i, lines[i], want[i])
}
}
}

// A stopped engine carries no current figures, so the combined format draws
// the retained readings alone — the sparkline lines with their blank label
// column, no gauges at all.
// A stopped engine carries no current figures, so the combined format draws
// each series' history alone: the gauge half blank, the series' label kept,
// and the bar's latest sample as the line's figure.
func TestRenderStatCombinedStoppedEngineDrawsHistoryAlone(t *testing.T) {
history := []metrics.HistorySample{
{Time: 1, CPU: ptrPct(10), Mem: ptrPct(20), GPUs: []metrics.HistoryGPU{{Index: 0, Util: 50, Mem: ptrPct(50)}}},
{Time: 2, CPU: ptrPct(20), Mem: ptrPct(30), GPUs: []metrics.HistoryGPU{{Index: 0, Util: 60, Mem: ptrPct(60)}}},
}
var b bytes.Buffer
renderStatCombined(&b, nil, nil, nil, history)
lines := strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n")
if len(lines) != 4 {
t.Fatalf("drew %d lines, want CPU, RAM, GPU util and GPU mem: %q", len(lines), b.String())
}
blankGauge := strings.Repeat(" ", serveGaugeW)
wantPrefix := []string{
" CPU " + blankGauge + " 20% ",
" RAM " + blankGauge + " 30% ",
" GPU util " + blankGauge + " 60% ",
" GPU mem " + blankGauge + " 60% ",
}
for i, want := range wantPrefix {
if !strings.HasPrefix(lines[i], want) {
t.Errorf("line %d:\ngot %q\nwant prefix %q", i, lines[i], want)
}
}
// The bar ends the line, its two samples as its two right-most glyphs,
// the newest in the state colour.
wantSuffix := []string{
"▁" + ansiGreen + "▂" + ansiReset,
"▂" + ansiGreen + "▃" + ansiReset,
"▄" + ansiGreen + "▅" + ansiReset,
"▄" + ansiGreen + "▅" + ansiReset,
}
for i, want := range wantSuffix {
if !strings.HasSuffix(lines[i], want) {
t.Errorf("line %d must end with the bar's two samples:\ngot %q", i, lines[i])
}
}
}

func TestFormatMetricsBarStoppedWithHistory(t *testing.T) {
resp := &remote.StatsResponse{
Environment: "prod", State: "stopped", ModelID: "org/qwen:q4",
Expand Down
Loading