diff --git a/cmd/spinloop/fleet_dashboard_test.go b/cmd/spinloop/fleet_dashboard_test.go index 5bf683af..01fed3a9 100644 --- a/cmd/spinloop/fleet_dashboard_test.go +++ b/cmd/spinloop/fleet_dashboard_test.go @@ -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) } diff --git a/cmd/spinloop/metrics_render.go b/cmd/spinloop/metrics_render.go index c4dbb6da..7f84d965 100644 --- a/cmd/spinloop/metrics_render.go +++ b/cmd/spinloop/metrics_render.go @@ -193,9 +193,9 @@ 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) @@ -203,21 +203,23 @@ func renderSparkline(w io.Writer, label string, samples []float64, width int) { // 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)) @@ -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) @@ -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) } } diff --git a/cmd/spinloop/metrics_render_test.go b/cmd/spinloop/metrics_render_test.go index 2c50db47..6d2df7ae 100644 --- a/cmd/spinloop/metrics_render_test.go +++ b/cmd/spinloop/metrics_render_test.go @@ -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 { @@ -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) - } } } @@ -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 @@ -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]) } } } @@ -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()) } } @@ -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()) @@ -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) { @@ -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) } diff --git a/docs/commands/cloud.md b/docs/commands/cloud.md index 64595430..ba0f922f 100644 --- a/docs/commands/cloud.md +++ b/docs/commands/cloud.md @@ -248,8 +248,10 @@ spinloop metrics --env -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 diff --git a/openspec/changes/archive/2026-10-15-sparkline-history-colours/.openspec.yaml b/openspec/changes/archive/2026-10-15-sparkline-history-colours/.openspec.yaml new file mode 100644 index 00000000..a216797d --- /dev/null +++ b/openspec/changes/archive/2026-10-15-sparkline-history-colours/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/archive/2026-10-15-sparkline-history-colours/design.md b/openspec/changes/archive/2026-10-15-sparkline-history-colours/design.md new file mode 100644 index 00000000..e66b8ba6 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-sparkline-history-colours/design.md @@ -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. diff --git a/openspec/changes/archive/2026-10-15-sparkline-history-colours/proposal.md b/openspec/changes/archive/2026-10-15-sparkline-history-colours/proposal.md new file mode 100644 index 00000000..19199911 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-sparkline-history-colours/proposal.md @@ -0,0 +1,56 @@ +# Proposal + +## Why + +The bar format's sparkline colours only the latest point — green at or +below 80%, yellow to 90%, red above — and leaves every earlier point in the +terminal's default colour (issue #265). A utilisation spike that happened +mid-window and has since passed is invisible in the one drawing that exists +to show pressure: the window is drawn as a line of bars, but a red spike +three minutes back reads as an unremarkable default-colour glyph, so the +user sees a calm line that just happened to be at 95%. + +## What Changes + +- The sparkline colours every point by its own value, on the gauge's + 80/90 thresholds: yellow for values from 80% to 90%, red for values above + 90%, and the terminal's default colour for values below 80%. Green is + dropped from the sparkline entirely — the gauge keeps its + green/yellow/red scheme, since its purpose is the current reading. +- The latest point is no longer special: it takes the colour of its own + value, like any other point. +- A threshold crossing is readable wherever it sits in the window: a spike + that passed earlier keeps its colour where it happened, and a calm window + stays uncoloured. + +## Capabilities + +### New Capabilities + +(None.) + +### Modified Capabilities + +- `remote-metrics-bar-format`: the "Colour thresholds" requirement is + removed and re-added as "Per-point colour thresholds" — every point of + the sparkline is colour-coded by its own utilisation, not only the + latest, and the scenarios cover historical points as well as the final + one. The removal is forced by the validator, which would not let a + modified block drop the "Green bar for low utilization" scenario, and + that scenario can no longer hold: the sparkline no longer draws green. +- `remote-stats`: the one sentence of the "The metrics report's tabular + display" requirement that describes the bar format's colouring (the + "latest point") is updated to the per-point wording. + +## Impact + +- `cmd/spinloop/metrics_render.go`: `sparklineBlock` only — the single + colouring point shared by `cloud metrics --format=bar`, + `fleet metrics --format=bar`, the dashboard's bar-format tiles, and the + serve view's bar half. The gauge and the no-history fallback are + untouched. +- Tests in `cmd/spinloop/metrics_render_test.go` and + `cmd/spinloop/fleet_dashboard_test.go` that assert the old + latest-point-only colouring. +- `docs/commands/cloud.md`: the paragraph describing the bar format's + "only the latest point coloured". diff --git a/openspec/changes/archive/2026-10-15-sparkline-history-colours/specs/remote-metrics-bar-format/spec.md b/openspec/changes/archive/2026-10-15-sparkline-history-colours/specs/remote-metrics-bar-format/spec.md new file mode 100644 index 00000000..b3e498cb --- /dev/null +++ b/openspec/changes/archive/2026-10-15-sparkline-history-colours/specs/remote-metrics-bar-format/spec.md @@ -0,0 +1,58 @@ +# Spec Delta + +## REMOVED Requirements + +### Requirement: Colour thresholds + +**Reason**: It coloured the latest point only — green at or below 80%, +yellow to 90%, red above — and left every earlier point plain. The +sparkline now colour-codes every point by its own value, and green no +longer appears at all: the "Green bar for low utilization" scenario +asserts a glyph colour the sparkline no longer draws, and the other two +single out the latest point as though it were special. + +**Migration**: Replaced by the "Per-point colour thresholds" requirement. +The 80/90 boundaries are unchanged — red above 90%, yellow from 80% to +90% — and the band below 80% takes the terminal's default colour instead +of green. The gauge is untouched: its fill keeps green/yellow/red. + +## ADDED Requirements + +### Requirement: Per-point colour thresholds + +The sparkline's points SHALL be colour-coded based on utilization: yellow +for values from 80% to 90%, and red for values above 90%. Every point +SHALL take the colour of its own value, the latest point included, and +every point below 80% SHALL appear in the terminal's default colour, so +the threshold crossings — wherever in the window they sit — are the ones +to read. + +#### Scenario: A historical point above 90% stays red + +- **WHEN** a series' retained history holds a sample above 90% earlier in the window and later samples fall below 80% +- **THEN** the earlier glyph appears in red and the later glyphs appear in the terminal's default colour + +#### Scenario: A historical point at 80% to 90% stays yellow + +- **WHEN** a series' retained history holds a sample from 80% to 90% earlier in the window and later samples fall below 80% +- **THEN** the earlier glyph appears in yellow and the later glyphs appear in the terminal's default colour + +#### Scenario: The latest point takes the colour of its own value + +- **WHEN** the latest sample of a series is 95% +- **THEN** the sparkline's final glyph appears in red + +#### Scenario: The latest point at 80% to 90% is yellow + +- **WHEN** the latest sample of a series is 85% +- **THEN** the sparkline's final glyph appears in yellow + +#### Scenario: A latest point below 80% is uncoloured + +- **WHEN** the latest sample of a series is 70% +- **THEN** the sparkline's final glyph appears in the terminal's default colour + +#### Scenario: A window below the thresholds stays uncoloured + +- **WHEN** every sample of a series is below 80%, the latest included +- **THEN** every glyph of the sparkline appears in the terminal's default colour diff --git a/openspec/changes/archive/2026-10-15-sparkline-history-colours/specs/remote-stats/spec.md b/openspec/changes/archive/2026-10-15-sparkline-history-colours/specs/remote-stats/spec.md new file mode 100644 index 00000000..10285501 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-sparkline-history-colours/specs/remote-stats/spec.md @@ -0,0 +1,47 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: The metrics report's tabular display + +The stats output SHALL support four formats via the `--format` flag: `gauge` (default), `bar`, `table`, and `json`. The `gauge` format SHALL produce a compact display with horizontal progress gauges for the current reading, colour-coded by utilization level. The `bar` format SHALL produce a compact display drawing each resource series as a sparkline of the daemon's retained history, with each point colour-coded by its own utilization level. The `table` format SHALL produce a tab-separated key-value table, one line per metric, with the key column left-aligned and values right of it. The `json` format SHALL output the response as a JSON object to standard output. Progress and error messages SHALL go to standard error regardless of format. + +#### Scenario: Clean output + +- **WHEN** the command succeeds +- **THEN** standard output contains only the stats data with no progress or debug lines + +#### Scenario: Default format is gauge + +- **WHEN** the user runs `spinloop metrics --env ` without `--format` +- **THEN** the output is in gauge format + +#### Scenario: Table format is explicit + +- **WHEN** the user runs `spinloop metrics --env --format=table` +- **THEN** the output is in table format + +#### Scenario: Bar format is explicit + +- **WHEN** the user runs `spinloop metrics --env --format=bar` +- **THEN** the output is in bar format, drawing each resource series as a sparkline of the daemon's retained history + +#### Scenario: Gauge format is explicit + +- **WHEN** the user runs `spinloop metrics --env --format=gauge` +- **THEN** the output is in gauge format with progress gauges for the current reading + +#### Scenario: JSON format + +- **WHEN** the user runs `spinloop metrics --env --format=json` +- **THEN** the output is valid JSON containing the instance state, runner, model, GPU info, CPU/RAM usage, and token counts + +#### Scenario: JSON format with cost + +- **WHEN** the user runs `spinloop metrics --env --format=json --cost` +- **THEN** the JSON output includes a cost estimate field + +#### Scenario: Invalid format errors + +- **WHEN** the user runs `spinloop metrics --env --format=csv` +- **THEN** the command exits with an error and usage message diff --git a/openspec/changes/archive/2026-10-15-sparkline-history-colours/tasks.md b/openspec/changes/archive/2026-10-15-sparkline-history-colours/tasks.md new file mode 100644 index 00000000..31dcea88 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-sparkline-history-colours/tasks.md @@ -0,0 +1,23 @@ +# Tasks + +## 1. Spec delta + +- [x] 1.1 Rework the "Colour thresholds" requirement in the change's `remote-metrics-bar-format` spec delta: remove it (a modified block cannot drop the "Green bar for low utilization" scenario, and the sparkline no longer draws green) and re-add it as "Per-point colour thresholds" — every point colour-coded by its own value, yellow from 80% to 90%, red above 90%, the terminal's default colour below 80% — with scenarios for a historical red point, a historical yellow point, the latest point coloured by its own value, and a fully below-threshold window. The main spec is updated when the change is archived. +- [x] 1.2 Update the bar-format sentence of the "The metrics report's tabular display" requirement in the change's `remote-stats` spec delta, from the "latest point colour-coded" wording to the per-point wording. + +## 2. Rendering (cmd/spinloop/metrics_render.go) + +- [x] 2.1 Rework `sparklineBlock` to colour each pooled column by its own value — red above 90, yellow from 80, plain below 80, the final column like any other — and update the comments that describe the latest-point-only colouring; verify with the colour tests below and `go test ./cmd/spinloop/`. + +## 3. Tests + +- [x] 3.1 Rework the colour assertions in `cmd/spinloop/metrics_render_test.go`: `TestRenderSparkline` (a below-threshold latest point is no longer green), `TestRenderSparklineColoursOnlyTheLastPoint` replaced by `TestRenderSparklineColoursEveryPoint` (a red spike and a yellow reading keep their colour where they sit) and `TestRenderSparklineColoursTheLastPointByItsValue` (the final point coloured by its own value, boundary-inclusive at 80 and 90), `TestRenderStatBarsDrawsHistory`, `TestRenderStatCombined`, `TestRenderStatCombinedStoppedEngineDrawsHistoryAlone`, and `TestFormatMetricsBarStoppedWithHistory`. +- [x] 3.2 Update `TestDashModelFormatToggle` in `cmd/spinloop/fleet_dashboard_test.go`: the CPU sparkline's final 20% glyph is no longer green. + +## 4. Docs + +- [x] 4.1 Update `docs/commands/cloud.md`: the bar-format description no longer says "only the latest point coloured"; it states the per-point colouring on the gauge's thresholds. + +## 5. Verify + +- [x] 5.1 `go test ./...`, `go vet ./...`, and `gofmt -l .` clean, with total coverage at or above 80%. diff --git a/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/.openspec.yaml b/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/.openspec.yaml new file mode 100644 index 00000000..b699b88b --- /dev/null +++ b/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-17 diff --git a/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/design.md b/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/design.md new file mode 100644 index 00000000..f5bbd9ae --- /dev/null +++ b/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/design.md @@ -0,0 +1,53 @@ +# Design + +## Context + +`renderStatCombined` in `cmd/spinloop/metrics_render.go` draws the serve +view's one line per series: the label, its gauge half at `serveGaugeW`, the +figure of the current reading, and its bar half at `serveBarW`. The figure +is the only element of the line whose width varies with the data — the +halves are fixed at their constants — and it is drawn with `%.0f%%`, so a +one-digit reading takes two columns, a two-digit three, and 100% four. The +bar half, drawn after the figure, starts at whatever column the figure +left: the sparkline shifts left or right as the reading crosses a digit +boundary. + +## Goals / Non-Goals + +**Goals:** + +- The bar half starts in the same column whatever the figure's digit + count. +- The line keeps one width across digit counts, so a live view does not + jitter a series' bar as its reading crosses a boundary. + +**Non-Goals:** + +- Changing what the figure shows — it stays the reading's percent, and + keeps the bar's latest sample as its fallback for a stopped engine. +- Touching the gauge, the sparkline, or any other format: they already + draw the figure after a fixed-width block. +- Changing `serveGaugeW` or `serveBarW`: the halves keep their widths and + the line keeps fitting the default 80-column window. + +## Decisions + +- **A fixed-width field of three digits plus the percent sign, + right-aligned (`%3.0f%%`).** The field is sized to the widest figure a + percent can take — 100 — not to the common two digits, so no further + growth is possible and the alignment does not depend on the value range. + A reading of 7 draws as ` 7%`; 42 as ` 42%`; 100 as `100%`. +- **Right-aligned, not left.** A percent is read from its right edge into + the percent sign; right-alignment keeps the sign in a fixed column, which + is where the eye lands before the bar. +- **The line gets one column wider for two-digit readings.** The field is + wider than `%.0f` drew a two-digit figure, so the line's total width + grows by one column. It still fits the 80-column window the halves are + sized for, and a stable width is the point: every width is equally + acceptable, only a shifting one is not. + +## Risks / Trade-offs + +- None material: the change is a single format verb on one line, and every + other surface draws the figure after a fixed-width block, so there is no + second surface to keep in step. diff --git a/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/proposal.md b/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/proposal.md new file mode 100644 index 00000000..22d0bc7a --- /dev/null +++ b/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/proposal.md @@ -0,0 +1,46 @@ +# Proposal + +## Why + +The serve view's combined line draws the figure of the reading unpadded, +between its gauge half and its bar half, so the bar's position depends on +the figure's digit count: a one-digit reading shifts the sparkline one +column left, and 100% — three digits — shifts it one column right. The view +is live and refreshes on its own cadence, so a series whose reading crosses +a digit boundary visibly jitters its bar from one frame to the next. The +serve view is the only surface where the figure sits before the bar: the +dashboard's tiles and the bar format draw the figure after a fixed-width +block, and a digit-count change is invisible there. + +## What Changes + +- The serve view's figure draws in a fixed-width field — three digits, + right-aligned, plus the percent sign — so a reading of one, two or three + digits draws the line the same width, with the bar half starting in the + same column. +- The combined line is one column wider than a two-digit reading drew it: + the field is sized to the widest figure a percent can take, not to the + common one. + +## Capabilities + +### New Capabilities + +(None.) + +### Modified Capabilities + +- `local-serving`: the "The serve view" requirement gains the fixed-width + figure field — the reading's digit count keeps the line the same width + and the bar half in the same column — with a scenario for a reading that + crosses a digit boundary. + +## Impact + +- `cmd/spinloop/metrics_render.go`: `renderStatCombined` only — the format + verb of the one line it draws, and its doc comment. `renderGauge`, + `renderSparkline` and the dashboard's tiles are untouched: they already + draw the figure after a fixed-width block. +- Tests in `cmd/spinloop/metrics_render_test.go`: the combined-format + expectations gain the field's padding, and a new test asserts the bar's + column across digit counts. diff --git a/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/specs/local-serving/spec.md b/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/specs/local-serving/spec.md new file mode 100644 index 00000000..b71607db --- /dev/null +++ b/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/specs/local-serving/spec.md @@ -0,0 +1,68 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: The serve view + +`spinloop serve` on a terminal SHALL open a full-screen view of the engine it +is running: the engine's metrics, the engine's log, and a line naming the keys +the view answers to, in the three-section layout of the fleet dashboard's node +detail screen. The view SHALL need a terminal to draw on: a run whose output +is not a terminal SHALL NOT open it, and `--dry-run` SHALL open it neither, +printing the command without launching as it does today. + +The view's metrics section SHALL show the same facts, in the same wording, +that the dashboard's detail screen and the metrics formats show for the same +engine — state, what is served, last active, the resource series, and the +token and request counters — read from the daemon the serve process runs +in-process rather than over the network, refreshed on the dashboard's own +local cadence. A reading the view could not renew SHALL be shown with its age +rather than drawn identically to one just read. + +The view SHALL draw every resource series the reading carries in both formats +at once, each series on one line: its gauge of the current reading and its +bar of the retained history side by side, the figure of the current reading +between the two halves — the same series and labelling the bar and gauge +formats use, with the bar format's no-history rule where a series has no +history to draw, so a series with none carries its gauge alone, its bar half +blank. The figure SHALL draw in a fixed-width field, so a reading of one, two +or three digits keeps the line the same width and the bar half in the same +column: a reading that crosses a digit boundary SHALL NOT shift the series' +bar. + +#### Scenario: The view opens on a terminal + +- **WHEN** the user runs `spinloop serve` at an interactive terminal +- **THEN** the view opens showing the engine's metrics above its log, with a + line naming the keys the view answers to + +#### Scenario: A piped run gets no view + +- **WHEN** `spinloop serve` runs with its stdout piped or redirected +- **THEN** no view opens and the engine's output is forwarded to serve's own + +#### Scenario: The metrics match the detail screen + +- **WHEN** the view is open on a running engine +- **THEN** its metrics section shows the same state, serving facts, last + active, resource series and counters the dashboard's detail screen shows + for the same engine, in full rather than clipped + +#### Scenario: Every series is drawn in both formats + +- **WHEN** the reading carries a CPU series with a retained history +- **THEN** the view draws the series on one line: its gauge of the current + reading and its bar of the retained history, side by side + +#### Scenario: A series with no history carries its gauge alone + +- **WHEN** a series has no retained history to draw +- **THEN** the view draws its gauge of the current reading only, its bar + half left blank + +#### Scenario: A reading that crosses a digit boundary does not shift the bar + +- **WHEN** a series' reading is 7, then 42, then 100, with the same retained + history each time +- **THEN** the line is drawn the same width at each reading, with the bar + half starting in the same column diff --git a/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/tasks.md b/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/tasks.md new file mode 100644 index 00000000..4c30d565 --- /dev/null +++ b/openspec/changes/archive/2026-10-17-serve-view-figure-alignment/tasks.md @@ -0,0 +1,18 @@ +# Tasks + +## 1. Spec delta + +- [x] 1.1 Modify the "The serve view" requirement in the change's `local-serving` spec delta: the figure between the two halves draws in a fixed-width field, so a reading of one, two or three digits keeps the line the same width and the bar half in the same column — with a scenario for a reading that crosses a digit boundary. Every scenario the current requirement has is kept. The main spec is updated when the change is archived. + +## 2. Rendering (cmd/spinloop/metrics_render.go) + +- [x] 2.1 Draw the figure in `renderStatCombined` in a fixed-width field — three digits, right-aligned, plus the percent sign (`%3.0f%%`) — and update the function's doc comment to say the bar half's column does not depend on the figure's digit count; verify with the tests below and `go test ./cmd/spinloop/`. + +## 3. Tests + +- [x] 3.1 Update the combined-format expectations in `cmd/spinloop/metrics_render_test.go` to the padded figure: `TestRenderStatCombined`, `TestRenderStatCombinedMemoryWithoutTotal`, `TestRenderStatCombinedWithoutHistory`, and `TestRenderStatCombinedStoppedEngineDrawsHistoryAlone`; the width assertion now counts the figure's fixed-width field. +- [x] 3.2 Add `TestRenderStatCombinedFigureDoesNotShiftTheBar`: the same history with readings of 0, 7, 42 and 100 each draws the line the same width, with the bar half in the same column. + +## 4. Verify + +- [x] 4.1 `go test ./...`, `go vet ./...`, and `gofmt -l .` clean, with total coverage at or above 80%. diff --git a/openspec/specs/local-serving/spec.md b/openspec/specs/local-serving/spec.md index 7ad4e6d5..8698b57c 100644 --- a/openspec/specs/local-serving/spec.md +++ b/openspec/specs/local-serving/spec.md @@ -383,10 +383,14 @@ rather than drawn identically to one just read. The view SHALL draw every resource series the reading carries in both formats at once, each series on one line: its gauge of the current reading and its -bar of the retained history side by side — the same series and labelling the -bar and gauge formats use, with the bar format's no-history rule where a -series has no history to draw, so a series with none carries its gauge -alone, its bar half blank. +bar of the retained history side by side, the figure of the current reading +between the two halves — the same series and labelling the bar and gauge +formats use, with the bar format's no-history rule where a series has no +history to draw, so a series with none carries its gauge alone, its bar half +blank. The figure SHALL draw in a fixed-width field, so a reading of one, two +or three digits keeps the line the same width and the bar half in the same +column: a reading that crosses a digit boundary SHALL NOT shift the series' +bar. #### Scenario: The view opens on a terminal @@ -418,6 +422,13 @@ alone, its bar half blank. - **THEN** the view draws its gauge of the current reading only, its bar half left blank +#### Scenario: A reading that crosses a digit boundary does not shift the bar + +- **WHEN** a series' reading is 7, then 42, then 100, with the same retained + history each time +- **THEN** the line is drawn the same width at each reading, with the bar + half starting in the same column + ### Requirement: The serve view's log The view's log section SHALL show the engine's log, tailing and following it diff --git a/openspec/specs/remote-metrics-bar-format/spec.md b/openspec/specs/remote-metrics-bar-format/spec.md index 66c17c05..74306d19 100644 --- a/openspec/specs/remote-metrics-bar-format/spec.md +++ b/openspec/specs/remote-metrics-bar-format/spec.md @@ -35,25 +35,40 @@ The system SHALL support a `--format=bar` option that renders each resource seri - **WHEN** the user runs `spinloop cloud metrics --format=bar` with a running instance - **THEN** the first line shows the environment, state, instance type, and model ID separated by double spaces -### Requirement: Colour thresholds +### Requirement: Per-point colour thresholds -The sparkline's latest point SHALL be colour-coded based on utilization: green for values at or below 80%, yellow for values from 80% to 90%, and red for values above 90%. Every earlier point SHALL appear in the terminal's default colour, so the coloured point is the one to read. +The sparkline's points SHALL be colour-coded based on utilization: yellow for values from 80% to 90%, and red for values above 90%. Every point SHALL take the colour of its own value, the latest point included, and every point below 80% SHALL appear in the terminal's default colour, so the threshold crossings — wherever in the window they sit — are the ones to read. -#### Scenario: Green bar for low utilization +#### Scenario: A historical point above 90% stays red -- **WHEN** the latest sample of a series is 70% -- **THEN** the sparkline's final glyph appears in green and the earlier glyphs appear in the terminal's default colour +- **WHEN** a series' retained history holds a sample above 90% earlier in the window and later samples fall below 80% +- **THEN** the earlier glyph appears in red and the later glyphs appear in the terminal's default colour -#### Scenario: Yellow bar for high utilization +#### Scenario: A historical point at 80% to 90% stays yellow -- **WHEN** the latest sample of a series is 85% -- **THEN** the sparkline's final glyph appears in yellow +- **WHEN** a series' retained history holds a sample from 80% to 90% earlier in the window and later samples fall below 80% +- **THEN** the earlier glyph appears in yellow and the later glyphs appear in the terminal's default colour -#### Scenario: Red bar for critical utilization +#### Scenario: The latest point takes the colour of its own value - **WHEN** the latest sample of a series is 95% - **THEN** the sparkline's final glyph appears in red +#### Scenario: The latest point at 80% to 90% is yellow + +- **WHEN** the latest sample of a series is 85% +- **THEN** the sparkline's final glyph appears in yellow + +#### Scenario: A latest point below 80% is uncoloured + +- **WHEN** the latest sample of a series is 70% +- **THEN** the sparkline's final glyph appears in the terminal's default colour + +#### Scenario: A window below the thresholds stays uncoloured + +- **WHEN** every sample of a series is below 80%, the latest included +- **THEN** every glyph of the sparkline appears in the terminal's default colour + ### Requirement: Gauge format is default The system SHALL use gauge format as the default output when no `--format` flag is specified. diff --git a/openspec/specs/remote-stats/spec.md b/openspec/specs/remote-stats/spec.md index 8636788c..c7d73f18 100644 --- a/openspec/specs/remote-stats/spec.md +++ b/openspec/specs/remote-stats/spec.md @@ -52,7 +52,7 @@ When the user passes `--cost`, the stats report SHALL include an estimated on-de ### Requirement: The metrics report's tabular display -The stats output SHALL support four formats via the `--format` flag: `gauge` (default), `bar`, `table`, and `json`. The `gauge` format SHALL produce a compact display with horizontal progress gauges for the current reading, colour-coded by utilization level. The `bar` format SHALL produce a compact display drawing each resource series as a sparkline of the daemon's retained history, with the latest point colour-coded by utilization level. The `table` format SHALL produce a tab-separated key-value table, one line per metric, with the key column left-aligned and values right of it. The `json` format SHALL output the response as a JSON object to standard output. Progress and error messages SHALL go to standard error regardless of format. +The stats output SHALL support four formats via the `--format` flag: `gauge` (default), `bar`, `table`, and `json`. The `gauge` format SHALL produce a compact display with horizontal progress gauges for the current reading, colour-coded by utilization level. The `bar` format SHALL produce a compact display drawing each resource series as a sparkline of the daemon's retained history, with each point colour-coded by its own utilization level. The `table` format SHALL produce a tab-separated key-value table, one line per metric, with the key column left-aligned and values right of it. The `json` format SHALL output the response as a JSON object to standard output. Progress and error messages SHALL go to standard error regardless of format. #### Scenario: Clean output