Skip to content
Merged
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
5 changes: 3 additions & 2 deletions cmd/spinloop/fleet_dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3441,8 +3441,9 @@ func TestDashModelFormatToggle(t *testing.T) {
}
// The bar format draws the retained readings: a fresh window, so leading
// blank columns, ending on the last retained sample — the current reading
// is the next point, not in the history yet.
cpuLine := " CPU " + strings.Repeat(" ", 23) + "▁" + ansiGreen + "▂" + ansiReset + " 20%"
// is the next point, not in the history yet. Both samples sit below the
// thresholds, so neither takes a colour.
cpuLine := " CPU " + strings.Repeat(" ", 23) + "▁▂" + " 20%"
if !strings.Contains(bar, cpuLine) {
t.Errorf("bar tile did not draw the history, want %q in:\n%s", cpuLine, bar)
}
Expand Down
40 changes: 23 additions & 17 deletions cmd/spinloop/metrics_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,31 +193,33 @@ func poolMax(values []float64, width int) []float64 {

// renderSparkline draws one resource series across width columns, one glyph
// per sample, newest on the right. The window's leading columns are blank
// while it still fills, the final glyph takes the state colour on the
// gauge's 80/90 thresholds, and the trailing figure is the latest sample's
// percentage — the exact value the last glyph approximates.
// while it still fills, each glyph takes the state colour of its own value
// on the 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.
// still fills, and each glyph in the state colour of its own value — the
// label and the trailing figure excluded — along with the latest sample the
// figure reports. A value below 80 takes no colour: the threshold crossings
// are the ones to read, wherever in the window they sit.
func sparklineBlock(samples []float64, width int) (string, float64) {
pooled := poolMax(samples, width)
last := pooled[len(pooled)-1]
colour := ansiGreen
if last > 90 {
colour = ansiRed
} else if last >= 80 {
colour = ansiYellow
}
var b strings.Builder
b.WriteString(strings.Repeat(" ", width-len(pooled)))
for i, v := range pooled {
if i == len(pooled)-1 {
for _, v := range pooled {
colour := ""
if v > 90 {
colour = ansiRed
} else if v >= 80 {
colour = ansiYellow
}
if colour != "" {
b.WriteString(fmt.Sprintf("%s%c%s", colour, barGlyph(v), ansiReset))
} else {
b.WriteRune(barGlyph(v))
Expand Down Expand Up @@ -392,9 +394,13 @@ const (
// 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.
// current one. The figure draws in a fixed-width field — three digits,
// right-aligned, plus the percent sign — so a reading that crosses a digit
// boundary keeps the line the same width and the bar half starts in the same
// column instead of shifting with the figure's digit count. 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)
Expand All @@ -411,7 +417,7 @@ func renderStatCombined(w io.Writer, cpu *metrics.CpuStat, mem *metrics.MemorySt
figure = last
}
}
fmt.Fprintf(w, " %-9s %s %.0f%% %s\n", s.label, gaugeHalf, figure, barHalf)
fmt.Fprintf(w, " %-9s %s %3.0f%% %s\n", s.label, gaugeHalf, figure, barHalf)
}
}

Expand Down
137 changes: 98 additions & 39 deletions cmd/spinloop/metrics_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,23 +79,42 @@ func TestRenderSparkline(t *testing.T) {
var b bytes.Buffer
renderSparkline(&b, "CPU", []float64{20, 30}, 40)
// The trailing figure is the latest sample's percentage — the exact value
// the last glyph approximates — not the series' first.
want := " CPU " + strings.Repeat(" ", 38) + "▂" + ansiGreen + "▃" + ansiReset + " 30%\n"
// the last glyph approximates — not the series' first. Both points sit
// below the 80% threshold, so neither takes a colour.
want := " CPU " + strings.Repeat(" ", 38) + "▂▃" + " 30%\n"
if got := b.String(); got != want {
t.Errorf("sparkline = %q, want %q", got, want)
}
}

// Only the final glyph takes the state colour, on the gauge's 80/90
// Every point takes the state colour of its own value, on the gauge's 80/90
// thresholds, whatever the rest of the window did — the window here holds a
// red spike earlier that must stay uncoloured.
func TestRenderSparklineColoursOnlyTheLastPoint(t *testing.T) {
// red spike and a yellow reading earlier, and they keep their colour where
// they sit while the calm points around them do not.
func TestRenderSparklineColoursEveryPoint(t *testing.T) {
var b bytes.Buffer
renderSparkline(&b, "CPU", []float64{5, 95, 85, 30}, 40)
want := " CPU " + strings.Repeat(" ", 36) +
"▁" + ansiRed + "▇" + ansiReset +
ansiYellow + "▆" + ansiReset +
"▃" + " 30%\n"
if got := b.String(); got != want {
t.Errorf("sparkline = %q, want %q", got, want)
}
}

// The latest point takes the colour of its own value, like any other point,
// on the gauge's thresholds with their boundaries: 80 itself is yellow, 90
// itself still is, and only above 90 turns red.
func TestRenderSparklineColoursTheLastPointByItsValue(t *testing.T) {
cases := []struct {
last float64
want string
}{
{79.9, ansiGreen + "▆" + ansiReset + " 80%\n"},
{79.9, "▆" + " 80%\n"},
{80, ansiYellow + "▆" + ansiReset + " 80%\n"},
{85, ansiYellow + "▆" + ansiReset + " 85%\n"},
{90, ansiYellow + "▇" + ansiReset + " 90%\n"},
{95, ansiRed + "▇" + ansiReset + " 95%\n"},
}
for _, c := range cases {
Expand All @@ -104,11 +123,6 @@ func TestRenderSparklineColoursOnlyTheLastPoint(t *testing.T) {
if !strings.HasSuffix(b.String(), c.want) {
t.Errorf("last point %v: %q, want suffix %q", c.last, b.String(), c.want)
}
// Every earlier glyph is uncoloured: the escapes appear once, around
// the final glyph only.
if n := strings.Count(b.String(), "\033["); n != 2 {
t.Errorf("last point %v: %d escape sequences, want 2", c.last, n)
}
}
}

Expand Down Expand Up @@ -170,12 +184,21 @@ func TestRenderStatBarsDrawsHistory(t *testing.T) {
if len(lines) != 2 {
t.Fatalf("drew %d lines, want CPU and RAM: %q", len(lines), b.String())
}
// The spike is the latest reading, so it is the last point and red; the
// earlier, calm points carry no colour.
if !strings.Contains(lines[0], ansiRed+"▇"+ansiReset+" 95%") {
t.Errorf("CPU line lost its spike or its red last point: %q", lines[0])
t.Errorf("CPU line lost its red spike: %q", lines[0])
}
if !strings.Contains(lines[1], ansiGreen+"▃"+ansiReset+" 40%") {
if strings.Contains(lines[0], ansiGreen) {
t.Errorf("CPU line coloured a below-threshold point green: %q", lines[0])
}
// The RAM readings sit below the thresholds, so none takes a colour.
if !strings.Contains(lines[1], "▃ 40%") {
t.Errorf("RAM line: %q", lines[1])
}
if strings.Contains(lines[1], "\033[") {
t.Errorf("RAM line coloured a below-threshold point: %q", lines[1])
}
}

// Two adjacent series both pinned at 100% draw at seven-eighths, so neither
Expand Down Expand Up @@ -339,26 +362,64 @@ func TestRenderStatCombined(t *testing.T) {
// 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
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.
// sparkline on the point that crosses the threshold — and the sparkline
// stops one grade short of the full block, so the 95% sample draws the
// top of the seven sub-full glyphs. Two colour pairs only: one around
// the gauge's fill, one around the red spike.
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])
}
if n := strings.Count(lines[0], "\033["); n != 4 {
t.Errorf("CPU line has %d escape sequences, want 4: %q", n, 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.
// side by side, aligned across the series. The width is label (12),
// gauge half, a space, the figure's fixed-width field (4: three digits
// right-aligned plus the percent sign), 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)
if w := lipgloss.Width(line); w != 12+serveGaugeW+1+4+1+serveBarW {
t.Errorf("line %d is %d columns wide, want %d: %q", i, w, 12+serveGaugeW+1+4+1+serveBarW, line)
}
}
}

// The figure is a fixed-width field, so the bar half starts in the same
// column whatever the reading's digit count: a value crossing a digit
// boundary keeps the line the same width instead of shifting the series'
// bar.
func TestRenderStatCombinedFigureDoesNotShiftTheBar(t *testing.T) {
history := []metrics.HistorySample{
{Time: 1, CPU: ptrPct(10)},
{Time: 2, CPU: ptrPct(40)},
}
cpuBar, _ := sparklineBlock([]float64{10, 40}, serveBarW)
cases := []struct {
value float64
figure string
}{
{0, " 0%"}, {7, " 7%"}, {42, " 42%"}, {100, "100%"},
}
for _, c := range cases {
var b bytes.Buffer
renderStatCombined(&b, &metrics.CpuStat{Utilization: c.value}, nil, nil, history)
lines := strings.Split(strings.TrimSuffix(b.String(), "\n"), "\n")
if len(lines) != 1 {
t.Fatalf("figure %s drew %d lines, want 1: %q", c.figure, len(lines), b.String())
}
want := " CPU " + gaugeBlock(c.value, serveGaugeW) + " " + c.figure + " " + cpuBar
if lines[0] != want {
t.Errorf("figure %s:\ngot %q\nwant %q", c.figure, lines[0], want)
}
if w := lipgloss.Width(lines[0]); w != 12+serveGaugeW+1+4+1+serveBarW {
t.Errorf("figure %s drew a %d-column line, want %d: %q", c.figure, w, 12+serveGaugeW+1+4+1+serveBarW, lines[0])
}
}
}
Expand All @@ -368,7 +429,7 @@ func TestRenderStatCombined(t *testing.T) {
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") {
if !strings.Contains(b.String(), " 0%") || strings.Contains(b.String(), "NaN") {
t.Errorf("a memory reading with no total: %q", b.String())
}
}
Expand All @@ -384,10 +445,10 @@ func TestRenderStatCombinedWithoutHistory(t *testing.T) {
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,
" 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())
Expand Down Expand Up @@ -418,29 +479,27 @@ func TestRenderStatCombinedStoppedEngineDrawsHistoryAlone(t *testing.T) {
}
blankGauge := strings.Repeat(" ", serveGaugeW)
wantPrefix := []string{
" CPU " + blankGauge + " 20% ",
" RAM " + blankGauge + " 30% ",
" GPU util " + blankGauge + " 60% ",
" GPU mem " + blankGauge + " 60% ",
" 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,
}
// The bar ends the line, its two samples as its two right-most glyphs;
// every reading sits below the thresholds, so none takes a colour.
wantSuffix := []string{"▁▂", "▂▃", "▄▅", "▄▅"}
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])
}
}
if strings.Contains(b.String(), "\033[") {
t.Errorf("a below-threshold point took a colour:\n%s", b.String())
}
}

func TestFormatMetricsBarStoppedWithHistory(t *testing.T) {
Expand All @@ -461,7 +520,7 @@ func TestFormatMetricsBarStoppedWithHistory(t *testing.T) {
}
want := "prod stopped org/qwen:q4\n" +
" active 12s ago\n" +
" CPU " + strings.Repeat(" ", 38) + "▁" + ansiGreen + "▂" + ansiReset + " 20%\n"
" CPU " + strings.Repeat(" ", 38) + "▁▂" + " 20%\n"
if got := b.String(); got != want {
t.Errorf("stopped bar = %q, want %q", got, want)
}
Expand Down
6 changes: 4 additions & 2 deletions docs/commands/cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,10 @@ spinloop metrics --env <name> -w # the same, redrawn every 60 seco
current reading per series as a filled progress gauge, coloured green at or
below 80%, yellow to 90%, red above. `--format=bar` draws each series as a
sparkline of the last 10 minutes instead, taken by the on-instance daemon at
its sampler's cadence while the engine ran, with only the latest point
coloured; a daemon that predates the history, or an engine with no reading
its sampler's cadence while the engine ran, with each point coloured by its
own value on the gauge's thresholds — yellow from 80% to 90%, red above 90%
— so a spike that passed earlier in the window stays visible where it
happened; a daemon that predates the history, or an engine with no reading
yet, falls back to the gauge drawing of the current reading, so the bar
output degrades rather than goes blank. `--format=table` is a key-value
table, and `--format=json` is the raw reply. A stopped engine keeps its
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-15
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Design

## Context

`sparklineBlock` in `cmd/spinloop/metrics_render.go` is the single point
where the bar format's colouring happens. It is shared by
`cloud metrics --format=bar`, `fleet metrics --format=bar`, the
dashboard's bar-format tiles, and the serve view's combined
gauge/sparkline rendering, so one change covers every surface. Today it
pools the retained samples to the line width with `poolMax` — keeping each
pool's highest reading, because a sparkline is about pressure and the
thresholds are about peaks — draws one block glyph per column, and applies
the state colour to the final glyph only, on the gauge's 80/90
thresholds; every other glyph is plain.

## Goals / Non-Goals

**Goals:**

- Every pooled column takes the colour of its own value: red above 90%,
yellow from 80% to 90%, the terminal's default colour below 80%.
- The latest point is treated like any other point.
- The gauge's green/yellow/red scheme and the no-history fallback's
gauge-styled drawing are untouched.

**Non-Goals:**

- Changing the thresholds — 80/90 stay, as the gauge's.
- Colouring the no-history fallback, which is drawn in the gauge's filled
style and keeps the gauge's scheme.
- A new state colour: the sparkline uses the two warning colours and
nothing else.

## Decisions

- **Per-point colouring on the pooled columns, not the raw samples.** The
line draws one glyph per column after `poolMax`, so the colour is applied
to the pooled value the glyph stands for. A pool keeps its peak, so a red
spike that pooled into a column keeps its red whether or not it was that
column's latest sample.
- **Green is dropped from the sparkline.** The sparkline reads as "the
crossings are what to read": anything that has not crossed the warning
threshold is the terminal's default colour. Colouring every below-80%
point green would paint the calm window and bury the yellow and red it
exists to show. The gauge keeps green, because it exists to say whether
the current reading is healthy.
- **No new palette entries.** The state colours already exist
(`ansiYellow`, `ansiRed`); a below-threshold point simply writes its
glyph without ANSI decoration.

## Risks / Trade-offs

- A sustained yellow or red window is busier than today's — that is the
point: it is the shape of the pressure to see.
- Colour-blind terminals already render the two warning colours via the
terminal's own palette; nothing here changes that.
- The `poolMax` comment already explains why peaks are kept; the colouring
change does not alter the pooling, so the rationale holds unchanged.
Loading
Loading