diff --git a/internal/cli/tui_actions_test.go b/internal/cli/tui_actions_test.go index 95889078..c425b6c1 100644 --- a/internal/cli/tui_actions_test.go +++ b/internal/cli/tui_actions_test.go @@ -3,6 +3,7 @@ package cli import ( "encoding/json" "io" + "math" "net/http" "net/http/httptest" "os" @@ -591,3 +592,25 @@ func TestANodesPageHasItsCharts(t *testing.T) { t.Errorf("%s", v) } } + +func TestAWindowsSamplesFillTheChartsWidth(t *testing.T) { + // Five minutes at five-second steps is sixty samples; the chart is a + // hundred and twenty dots across. Every column is drawn. + to := time.Now() + s := chartSeries{from: to.Add(-5 * time.Minute), to: to} + for i := 0; i < 60; i++ { + s.at = append(s.at, to.Add(-5*time.Minute+time.Duration(i)*5*time.Second)) + s.values = append(s.values, 50) + } + for c, v := range s.fit(120) { + if math.IsNaN(v) { + t.Fatalf("column %d of 120 empty", c) + } + } + // A series that began two minutes ago fills the last two fifths only. + s.at, s.values = s.at[36:], s.values[36:] + got := s.fit(100) + if !math.IsNaN(got[50]) || math.IsNaN(got[70]) || math.IsNaN(got[99]) { + t.Errorf("a late start: %v", got) + } +} diff --git a/internal/cli/tui_chart.go b/internal/cli/tui_chart.go index aadf0884..aeca794f 100644 --- a/internal/cli/tui_chart.go +++ b/internal/cli/tui_chart.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "strings" + "time" "github.com/charmbracelet/lipgloss" ) @@ -42,6 +43,54 @@ var lineColors = []lipgloss.Color{"#61afef", "#e5c07b", "#c678dd", "#56b6c2", "# type chartSeries struct { name string values []float64 + // at is when each value was sampled, and from and to the window drawn: + // with them points are placed by time, across the whole width. Without, + // the values are spread evenly over it. + at []time.Time + from, to time.Time +} + +// fit lays a series out in n columns. +func (s chartSeries) fit(n int) []float64 { + if len(s.at) == len(s.values) && len(s.at) > 0 && s.to.After(s.from) { + return byTime(s.values, s.at, s.from, s.to, n) + } + return resample(s.values, n) +} + +// byTime puts each sample in the column of its time; a column with none +// carries the last value on, unless the last is further back than a gap in +// the samples, which is drawn as one. Before the first sample is nothing. +func byTime(vals []float64, at []time.Time, from, to time.Time, n int) []float64 { + out := make([]float64, n) + width := to.Sub(from) / time.Duration(n) + // The spacing the samples were taken at; twice it is a gap. + gap := width + if len(at) > 1 { + gap = max(gap, 2*at[len(at)-1].Sub(at[0])/time.Duration(len(at)-1)) + } + j := 0 + last, lastAt := math.NaN(), time.Time{} + for c := range out { + end := from.Add(time.Duration(c+1) * width) + sum, k := 0.0, 0 + for ; j < len(vals) && !at[j].After(end); j++ { + if !math.IsNaN(vals[j]) { + sum += vals[j] + k++ + last, lastAt = vals[j], at[j] + } + } + switch { + case k > 0: + out[c] = sum / float64(k) + case !lastAt.IsZero() && end.Sub(lastAt) <= gap: + out[c] = last + default: + out[c] = math.NaN() + } + } + return out } // brailleChart draws series into width×height cells. top is the value at the @@ -81,7 +130,7 @@ func brailleChart(series []chartSeries, width, height int, top float64) []string } area := len(series) == 1 for si, s := range series { - vals := resample(s.values, cols) + vals := s.fit(cols) prev := -1 for x, v := range vals { if math.IsNaN(v) { @@ -137,7 +186,11 @@ func resample(vals []float64, n int) []float64 { return out } if len(vals) <= n { - copy(out[n-len(vals):], vals) + // Fewer values than columns: each held across its share of them, + // so the series spans the chart rather than huddling at its right. + for i := range out { + out[i] = vals[i*len(vals)/n] + } return out } per := float64(len(vals)) / float64(n) @@ -161,7 +214,12 @@ var sparkRunes = []rune("▁▂▃▄▅▆▇█") // sparkline is values in one row of block characters, coloured by height. func sparkline(vals []float64, width int, top float64) string { - vals = resample(vals, width) + return sparkSeries(chartSeries{values: vals}, width, top) +} + +// sparkSeries is sparkline for a series that knows its times. +func sparkSeries(s chartSeries, width int, top float64) string { + vals := s.fit(width) if top <= 0 { for _, v := range vals { if !math.IsNaN(v) && v > top { diff --git a/internal/cli/tui_monitor.go b/internal/cli/tui_monitor.go index a974f5ee..13cbc993 100644 --- a/internal/cli/tui_monitor.go +++ b/internal/cli/tui_monitor.go @@ -201,11 +201,27 @@ func (m consoleModel) series(id string) []chartSeries { if !m.mon.res.Configured { src = m.mon.localSeries(id) } + return toChart(src, m.mon.res.Configured, m.mon.at, monRanges[m.mon.rng].window) +} + +// toChart turns what Prometheus gave into chart series placed by time over +// the window that ended when it was read; sampled here, they are spread +// over what was seen. +func toChart(src []promq.Series, timed bool, readAt time.Time, window time.Duration) []chartSeries { out := make([]chartSeries, 0, len(src)) for _, s := range src { cs := chartSeries{name: s.Name} + stamped := timed for _, p := range s.Points { cs.values = append(cs.values, p.Value) + cs.at = append(cs.at, p.At) + stamped = stamped && !p.At.IsZero() + } + if !stamped { + cs.at = nil + } + if stamped && !readAt.IsZero() { + cs.from, cs.to = readAt.Add(-window), readAt } out = append(out, cs) } @@ -332,10 +348,10 @@ func (m consoleModel) monitorPanel(pw int) (string, string, string) { } netCol := "" if v, ok := netBy[nd.ID]; ok { - netCol = fmt.Sprintf(" net %s %9s", sparkline(v, 12, 0), bps(last(v))) + netCol = fmt.Sprintf(" net %s %9s", sparkSeries(v, 12, 0), bps(last(v.values))) } fmt.Fprintf(&b, " %-16s %s %4.0f%% %s %s %4.0f%% %s%s\n", truncate(nd.ID, 16), - meter(c/100, 8), c, sparkline(cpuBy[nd.ID], sw, 100), meter(mm/100, 8), mm, sparkline(memBy[nd.ID], sw, 100), netCol) + meter(c/100, 8), c, sparkSeries(cpuBy[nd.ID], sw, 100), meter(mm/100, 8), mm, sparkSeries(memBy[nd.ID], sw, 100), netCol) } // Each card: busy, memory, heat and power. if gpus := m.series("gpu"); len(gpus) > 0 { @@ -343,15 +359,15 @@ func (m consoleModel) monitorPanel(pw int) (string, string, string) { b.WriteString("\n " + mdBold.Render("GPUs") + "\n") for _, g := range gpus { fmt.Fprintf(&b, " %-16s busy %s %4.0f%% memory %s %4.0f%% %3.0f°C %s %4.0f W\n", truncate(g.name, 16), - sparkline(g.values, sw/2, 100), last(g.values), sparkline(vram[g.name], sw/2, 100), last(vram[g.name]), - last(temp[g.name]), sparkline(temp[g.name], 8, 100), last(pow[g.name])) + sparkSeries(g, sw/2, 100), last(g.values), sparkSeries(vram[g.name], sw/2, 100), last(vram[g.name].values), + last(temp[g.name].values), sparkSeries(temp[g.name], 8, 100), last(pow[g.name].values)) } } // Each model's requests. if rps := m.series("rpsby"); len(rps) > 0 { b.WriteString("\n " + mdBold.Render("Models") + sDim.Render(" requests a second") + "\n") for _, s := range rps { - fmt.Fprintf(&b, " %-30s %s %s\n", truncate(s.name, 30), sparkline(s.values, max(20, pw-50), 0), rate(last(s.values))) + fmt.Fprintf(&b, " %-30s %s %s\n", truncate(s.name, 30), sparkSeries(s, max(20, pw-50), 0), rate(last(s.values))) } } return "Monitor", head.String() + b.String(), keys @@ -364,10 +380,10 @@ func named(s []chartSeries, name string) []chartSeries { return s } -func byName(s []chartSeries) map[string][]float64 { - out := map[string][]float64{} +func byName(s []chartSeries) map[string]chartSeries { + out := map[string]chartSeries{} for _, x := range s { - out[x.name] = x.values + out[x.name] = x } return out } @@ -469,15 +485,7 @@ func (m consoleModel) detailCharts(pw int) string { return head + " " + sBad.Render(m.det.err.Error()) } get := func(qid string) []chartSeries { - var out []chartSeries - for _, s := range m.det.res.Series[qid] { - cs := chartSeries{name: s.Name} - for _, p := range s.Points { - cs.values = append(cs.values, p.Value) - } - out = append(out, cs) - } - return out + return toChart(m.det.res.Series[qid], true, m.det.at, r.window) } if !m.det.res.Configured { // Without Prometheus, a node's own samples from this console.