From a49cf11e1661df19189ce9c7c805faedaf5cf9c6 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 7 Aug 2026 12:08:04 -0700 Subject: [PATCH] telemetry: version distribution over time (#10625) * telemetry: keep the reported version in the daily history The version only ever lived on the instance record, which holds a cluster's latest report, so there was no way to ask what anything ran last Tuesday. Record it per sample, and let the daily axis carry strings as well as counts. State written before this has no version on its samples. The newest sample is the report the instance record itself came from, so fill that one in on load rather than starting a version series a day late. * telemetry: serve the fleet's version make-up over time /api/versions gives how many clusters ran each release per day, on the same axis and hold-forward rule as the cluster sizes. Releases are ordered by number rather than by size: the caller stacks them, and a stack whose order changes with the counts is unreadable over time. The tail past the limit is summed into "other" so the stack still adds up. Days with no version are dropped before the axis is built, so the series spans the days it knows a version for instead of climbing out of blanks. * telemetry: draw version distribution as a stacked growth chart The pie only ever showed today. Stacked over 30 days the height is the confirmed fleet and each band is a release, so one chart carries the growth and the rollouts at once. Newest release on the floor, so the band being read is anchored to the axis instead of riding on everything below it. Eight fixed hues instead of the evenly spaced ones the cluster stacks use: evenly spaced put a green and a cyan close enough to be hard to tell apart, which matters for a set you read rather than a wall of anonymous ids. Versions past the eighth fold into "other", and each band carries its own number so the chart reads without matching colours against the legend. --- telemetry/README.md | 4 + telemetry/server/api/handlers.go | 24 +++ telemetry/server/dashboard/dashboard.go | 126 +++++++++++--- telemetry/server/main.go | 1 + telemetry/server/storage/history.go | 11 +- telemetry/server/storage/persistence.go | 13 +- telemetry/server/storage/persistence_test.go | 35 ++++ telemetry/server/storage/prometheus.go | 4 +- telemetry/server/storage/sizes.go | 4 +- telemetry/server/storage/versions.go | 163 +++++++++++++++++++ telemetry/server/storage/versions_test.go | 109 +++++++++++++ 11 files changed, 462 insertions(+), 32 deletions(-) create mode 100644 telemetry/server/storage/versions.go create mode 100644 telemetry/server/storage/versions_test.go diff --git a/telemetry/README.md b/telemetry/README.md index 4f14cfce7..b734d1f12 100644 --- a/telemetry/README.md +++ b/telemetry/README.md @@ -184,6 +184,10 @@ GET /api/history?cluster_id=&days=90 # Get per-cluster disk usage and volume servers over time, largest first, # the rest summed as "other" GET /api/cluster-sizes?days=30&limit=20 + +# Get how many clusters ran each version over time, oldest version first, +# the rest summed as "other" +GET /api/versions?days=30&limit=8 ``` ### Monitoring diff --git a/telemetry/server/api/handlers.go b/telemetry/server/api/handlers.go index a4c767ebc..4dbf30b30 100644 --- a/telemetry/server/api/handlers.go +++ b/telemetry/server/api/handlers.go @@ -174,6 +174,30 @@ func (h *Handler) GetClusterSizes(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(h.storage.GetClusterSizeSeries(days, limit)) } +func (h *Handler) GetVersions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + days := 30 // default + if daysStr := r.URL.Query().Get("days"); daysStr != "" { + if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 365 { + days = d + } + } + + limit := 8 // default, the number of colours the version stack has + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { + limit = l + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(h.storage.GetVersionSeries(days, limit)) +} + func (h *Handler) GetHistory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) diff --git a/telemetry/server/dashboard/dashboard.go b/telemetry/server/dashboard/dashboard.go index c68dc0eb5..c52236554 100644 --- a/telemetry/server/dashboard/dashboard.go +++ b/telemetry/server/dashboard/dashboard.go @@ -143,7 +143,10 @@ func (h *Handler) ServeIndex(w http.ResponseWriter, r *http.Request) {
Version Distribution
- +
+
+ +
@@ -197,10 +200,15 @@ func (h *Handler) ServeIndex(w http.ResponseWriter, r *http.Request) { const sizesResponse = await fetch('/api/cluster-sizes?days=30&limit=20'); const sizes = await sizesResponse.json(); + // Load the fleet's version make-up over time + const versionsResponse = await fetch('/api/versions?days=30&limit=8'); + const versions = await versionsResponse.json(); + updateStats(stats); - updateCharts(stats); + createPieChart('osChart', stats.os_distribution || {}); + updateVersions(versions); updateClusterSizes(sizes); - + document.getElementById('loading').style.display = 'none'; document.getElementById('dashboard').style.display = 'block'; } catch (error) { @@ -217,12 +225,7 @@ func (h *Handler) ServeIndex(w http.ResponseWriter, r *http.Request) { document.getElementById('totalOS').textContent = Object.keys(stats.os_distribution || {}).length; } - function updateCharts(stats) { - createPieChart('versionChart', 'Version Distribution', stats.versions || {}); - createPieChart('osChart', 'Operating System Distribution', stats.os_distribution || {}); - } - - function createPieChart(canvasId, title, data) { + function createPieChart(canvasId, data) { const ctx = document.getElementById(canvasId).getContext('2d'); if (charts[canvasId]) { @@ -298,6 +301,75 @@ func (h *Handler) ServeIndex(w http.ResponseWriter, r *http.Request) { return (unit === 0 ? value : value.toFixed(value >= 100 ? 0 : 1)) + ' ' + units[unit]; } + // Fixed hues rather than the evenly spaced ones the cluster stacks use: + // a handful of versions is a set you read, and evenly spaced hues put + // pairs next to each other that colour-blind readers can't separate. + const versionColors = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', + '#e87ba4', '#008300', '#4a3aa7', '#e34948']; + + // One stacked band per version: the band is how many clusters ran that + // version that day, the top of the stack is the confirmed fleet, so the + // chart shows both how it grows and what it upgrades to. + function updateVersions(series) { + const versions = series.versions || []; + const dates = series.dates || []; + const total = series.total_clusters || 0; + document.getElementById('versionTotal').textContent = + total + ' cluster' + (total === 1 ? '' : 's') + ' on ' + (dates[dates.length - 1] || 'no data'); + + // Newest release on the floor, oldest on top: the current release + // is the band being read, and one anchored to the baseline reads + // straight off the axis instead of riding on everything below it. + // Colours follow the same order, so a release keeps its colour as + // older ones age out from the top. + const datasets = versions.slice().reverse().map((v, i) => + band(v.version, v.clusters, '#ffffff', versionColors[i % versionColors.length], 2)); + if (series.other) { + datasets.push(band('other (' + series.other.count + ' versions)', series.other.clusters, + '#ffffff', '#9a9a94', 2)); + } + + stackedArea('versionChart', dates, datasets, value => value, { plugins: [bandLabels] }); + } + + // Writes each version into its own band, so the chart reads without + // matching colours against the legend. Bands too thin to hold the text + // keep it, and the halo carries it over whatever it crosses. + const bandLabels = { + id: 'bandLabels', + afterDatasetsDraw(chart) { + const ctx = chart.ctx; + ctx.save(); + ctx.font = '600 12px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.lineJoin = 'round'; + chart.data.datasets.forEach((dataset, d) => { + const meta = chart.getDatasetMeta(d); + if (meta.hidden) return; + // Label where the band is thickest, so the text has room. + let at = -1, thickest = 0; + dataset.data.forEach((value, i) => { + if (value > thickest) { + thickest = value; + at = i; + } + }); + const point = at >= 0 && meta.data[at]; + if (!point) return; + const below = d === 0 ? chart.scales.y.getPixelForValue(0) + : chart.getDatasetMeta(d - 1).data[at].y; + if (below - point.y < 18) return; + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)'; + ctx.strokeText(dataset.label, point.x, (point.y + below) / 2); + ctx.fillStyle = '#1a1a1a'; + ctx.fillText(dataset.label, point.x, (point.y + below) / 2); + }); + ctx.restore(); + } + }; + // Disk usage and volume servers both drawn as one stacked band per // cluster over time: the band is that cluster's share, the top of the // stack is the fleet total. Clusters beyond the requested limit are @@ -337,6 +409,24 @@ func (h *Handler) ServeIndex(w http.ResponseWriter, r *http.Request) { '#9E9E9E', 'rgba(158, 158, 158, 0.6)')); } + stackedArea(canvasId, dates, datasets, format, { + onClick: (event, elements) => { + const id = elements.length && clusterSizeIds[elements[0].datasetIndex]; + if (id) { + document.getElementById('clusterIdInput').value = id; + loadClusterHistory(); + } + }, + // The legend shows shortened ids; the tooltip has room for the + // full one to paste into the lookup box. + label: item => (clusterSizeIds[item.datasetIndex] || item.dataset.label) + ': ' + format(item.raw) + }); + } + + // Draws the bands as one stack, so their heights add up to the day's + // total. hooks.onClick, hooks.label and hooks.plugins are optional. + function stackedArea(canvasId, dates, datasets, format, hooks) { + hooks = hooks || {}; const ctx = document.getElementById(canvasId).getContext('2d'); if (charts[canvasId]) { charts[canvasId].destroy(); @@ -344,25 +434,17 @@ func (h *Handler) ServeIndex(w http.ResponseWriter, r *http.Request) { charts[canvasId] = new Chart(ctx, { type: 'line', data: { labels: dates, datasets: datasets }, + plugins: hooks.plugins, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'band', intersect: false }, - onClick: (event, elements) => { - const id = elements.length && clusterSizeIds[elements[0].datasetIndex]; - if (id) { - document.getElementById('clusterIdInput').value = id; - loadClusterHistory(); - } - }, + onClick: hooks.onClick, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } }, tooltip: { callbacks: { - label: item => { - const id = clusterSizeIds[item.datasetIndex]; - return (id || item.dataset.label) + ': ' + format(item.raw); - } + label: hooks.label || (item => item.dataset.label + ': ' + format(item.raw)) } } }, @@ -396,13 +478,13 @@ func (h *Handler) ServeIndex(w http.ResponseWriter, r *http.Request) { return []; }; - function band(label, data, borderColor, backgroundColor) { + function band(label, data, borderColor, backgroundColor, borderWidth) { return { label: label, data: data, borderColor: borderColor, backgroundColor: backgroundColor, - borderWidth: 1, + borderWidth: borderWidth || 1, pointRadius: 0, pointHitRadius: 8, fill: true, diff --git a/telemetry/server/main.go b/telemetry/server/main.go index 0383c1d28..6acfc0da2 100644 --- a/telemetry/server/main.go +++ b/telemetry/server/main.go @@ -77,6 +77,7 @@ func main() { mux.HandleFunc("/api/metrics", corsMiddleware(logMiddleware(apiHandler.GetMetrics))) mux.HandleFunc("/api/history", corsMiddleware(logMiddleware(apiHandler.GetHistory))) mux.HandleFunc("/api/cluster-sizes", corsMiddleware(logMiddleware(apiHandler.GetClusterSizes))) + mux.HandleFunc("/api/versions", corsMiddleware(logMiddleware(apiHandler.GetVersions))) // Dashboard (optional) if *enableDashboard { diff --git a/telemetry/server/storage/history.go b/telemetry/server/storage/history.go index 8a3776650..e51d9a786 100644 --- a/telemetry/server/storage/history.go +++ b/telemetry/server/storage/history.go @@ -20,6 +20,7 @@ type HistorySample struct { TotalDiskBytes uint64 `json:"disk"` TotalVolumeCount int32 `json:"volumes"` VolumeServerCount int32 `json:"servers"` + Version string `json:"ver,omitempty"` // empty in samples written before this was recorded } // appendHistory records the report as the cluster's sample for the day, @@ -30,6 +31,7 @@ func (s *PrometheusStorage) appendHistory(data *proto.TelemetryData, receivedAt TotalDiskBytes: data.TotalDiskBytes, TotalVolumeCount: data.TotalVolumeCount, VolumeServerCount: data.VolumeServerCount, + Version: data.Version, } h := s.histories[data.TopologyId] if n := len(h); n > 0 && sameUTCDay(h[n-1].Ts, sample.Ts) { @@ -110,16 +112,17 @@ func utcDay(ts int64) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) } -func diskBytes(s HistorySample) uint64 { return s.TotalDiskBytes } -func serverCount(s HistorySample) uint64 { return uint64(s.VolumeServerCount) } +func diskBytes(s HistorySample) uint64 { return s.TotalDiskBytes } +func serverCount(s HistorySample) uint64 { return uint64(s.VolumeServerCount) } +func sampleVersion(s HistorySample) string { return s.Version } // align lays one cluster's history onto the axis, picking `value` out of each // sample. Clusters report roughly once a day at no fixed hour, so a day without // a report carries the previous value forward rather than dropping to zero; a // cluster that stopped reporting altogether ends at its last sample instead of // holding capacity forever. Reports false when the cluster has nothing in range. -func (d dailySeries) align(history []HistorySample, activeSince int64, value func(HistorySample) uint64) ([]uint64, bool) { - out := make([]uint64, len(d.dates)) +func align[T any](d dailySeries, history []HistorySample, activeSince int64, value func(HistorySample) T) ([]T, bool) { + out := make([]T, len(d.dates)) reported := make([]bool, len(d.dates)) first, last := -1, -1 for _, sample := range history { diff --git a/telemetry/server/storage/persistence.go b/telemetry/server/storage/persistence.go index 009108c84..a61740ec7 100644 --- a/telemetry/server/storage/persistence.go +++ b/telemetry/server/storage/persistence.go @@ -44,9 +44,18 @@ func (s *PrometheusStorage) LoadState(path string) (int, error) { loaded++ } for id, h := range state.Histories { - if _, ok := s.instances[id]; ok && len(h) > 0 { - s.histories[id] = h + instance, ok := s.instances[id] + if !ok || len(h) == 0 { + continue } + // State written before versions were recorded carries none on its + // samples. The newest sample is the report the instance record itself + // came from, so that day's version is known and the version series can + // start there rather than a day after the upgrade. + if newest := len(h) - 1; h[newest].Version == "" { + h[newest].Version = instance.TelemetryData.Version + } + s.histories[id] = h } s.updateStats() return loaded, nil diff --git a/telemetry/server/storage/persistence_test.go b/telemetry/server/storage/persistence_test.go index 62b949ef8..df11545f6 100644 --- a/telemetry/server/storage/persistence_test.go +++ b/telemetry/server/storage/persistence_test.go @@ -72,3 +72,38 @@ func TestStateRoundTrip(t *testing.T) { t.Error("dirty flag set after load") } } + +// State written before versions were recorded still knows the version of the +// report its newest sample came from: the instance record's. +func TestLoadStateFillsNewestSampleVersion(t *testing.T) { + path := filepath.Join(t.TempDir(), "telemetry-state.json") + + s := newPrometheusStorage(prometheus.NewRegistry()) + report := &proto.TelemetryData{TopologyId: "test-cluster-1", Version: "4.40", Os: "linux/amd64"} + if err := s.StoreTelemetry(report); err != nil { + t.Fatalf("store: %v", err) + } + yesterday := time.Now().AddDate(0, 0, -1).Unix() + s.histories[report.TopologyId] = []HistorySample{ + {Ts: yesterday, TotalDiskBytes: 10}, + {Ts: time.Now().Unix(), TotalDiskBytes: 20}, + } + if err := s.SaveStateIfDirty(path); err != nil { + t.Fatalf("save: %v", err) + } + + s = newPrometheusStorage(prometheus.NewRegistry()) + if _, err := s.LoadState(path); err != nil { + t.Fatalf("load: %v", err) + } + loaded := s.histories[report.TopologyId] + if len(loaded) != 2 { + t.Fatalf("history = %+v, want 2 samples", loaded) + } + if loaded[1].Version != report.Version { + t.Errorf("newest sample version = %q, want %q", loaded[1].Version, report.Version) + } + if loaded[0].Version != "" { + t.Errorf("older sample version = %q, want it left unknown", loaded[0].Version) + } +} diff --git a/telemetry/server/storage/prometheus.go b/telemetry/server/storage/prometheus.go index 3a3eea59e..8c664e090 100644 --- a/telemetry/server/storage/prometheus.go +++ b/telemetry/server/storage/prometheus.go @@ -180,12 +180,12 @@ func (s *PrometheusStorage) GetMetrics(days int) (map[string]interface{}, error) diskUsage := make([]uint64, len(axis.dates)) serverCounts := make([]int64, len(axis.dates)) for _, history := range histories { - if disk, ok := axis.align(history, activeSince, diskBytes); ok { + if disk, ok := align(axis, history, activeSince, diskBytes); ok { for i, v := range disk { diskUsage[i] += v } } - if servers, ok := axis.align(history, activeSince, serverCount); ok { + if servers, ok := align(axis, history, activeSince, serverCount); ok { for i, v := range servers { serverCounts[i] += int64(v) } diff --git a/telemetry/server/storage/sizes.go b/telemetry/server/storage/sizes.go index a4ae77bde..434f36c06 100644 --- a/telemetry/server/storage/sizes.go +++ b/telemetry/server/storage/sizes.go @@ -48,11 +48,11 @@ func (s *PrometheusStorage) GetClusterSizeSeries(days, limit int) ClusterSizeSer series := ClusterSizeSeries{Dates: axis.dates} for id, history := range histories { - disk, ok := axis.align(history, activeSince, diskBytes) + disk, ok := align(axis, history, activeSince, diskBytes) if !ok { continue } - servers, _ := axis.align(history, activeSince, serverCount) + servers, _ := align(axis, history, activeSince, serverCount) series.Clusters = append(series.Clusters, ClusterSeries{ClusterId: id, Disk: disk, Servers: servers}) series.TotalDisk += disk[last] series.TotalServers += servers[last] diff --git a/telemetry/server/storage/versions.go b/telemetry/server/storage/versions.go new file mode 100644 index 000000000..57b83445f --- /dev/null +++ b/telemetry/server/storage/versions.go @@ -0,0 +1,163 @@ +package storage + +import ( + "sort" + "strconv" + "strings" + "time" +) + +// VersionCounts is how many clusters ran one version per day, aligned to the +// shared date axis of the enclosing VersionSeries. +type VersionCounts struct { + Version string `json:"version"` + Clusters []uint64 `json:"clusters"` +} + +// OtherVersions is the versions beyond the caller's limit, summed per day so a +// stacked chart still adds up to the cluster total. +type OtherVersions struct { + Count int `json:"count"` + Clusters []uint64 `json:"clusters"` +} + +// VersionSeries is the version make-up of the fleet over time: one cluster +// count per version per day, oldest release first. +type VersionSeries struct { + Dates []string `json:"dates"` + Versions []VersionCounts `json:"versions"` + Other *OtherVersions `json:"other,omitempty"` + TotalClusters uint64 `json:"total_clusters"` // across all versions on the last day +} + +// GetVersionSeries returns the last `days` days of per-version cluster counts +// across confirmed clusters. Versions beyond `limit` are folded into Other, +// keeping the ones with the most clusters on the last day. +func (s *PrometheusStorage) GetVersionSeries(days, limit int) VersionSeries { + s.mu.RLock() + defer s.mu.RUnlock() + + histories := versionedHistories(s.seriesHistories()) + axis := newDailySeries(days, histories) + activeSince := time.Now().UTC().AddDate(0, 0, -activeDays).Unix() + last := len(axis.dates) - 1 + series := VersionSeries{Dates: axis.dates} + + daily := make(map[string][]uint64) + for _, history := range histories { + versions, ok := align(axis, history, activeSince, sampleVersion) + if !ok { + continue + } + for i, v := range versions { + if v == "" { + continue // before the cluster's first day in the window + } + counts, ok := daily[v] + if !ok { + counts = make([]uint64, len(axis.dates)) + daily[v] = counts + } + counts[i]++ + } + } + + for v, counts := range daily { + series.Versions = append(series.Versions, VersionCounts{Version: v, Clusters: counts}) + series.TotalClusters += counts[last] + } + // Ordered by release rather than by size: the caller stacks them, and a + // stack whose order changes with the counts is unreadable over time. + sort.Slice(series.Versions, func(i, j int) bool { + return versionLess(series.Versions[i].Version, series.Versions[j].Version) + }) + + if limit > 0 && len(series.Versions) > limit { + // Old releases are a long tail of one-cluster bands; keep the versions + // most of the fleet is on now and sum the rest into one band. + ranked := append([]VersionCounts(nil), series.Versions...) + sort.Slice(ranked, func(i, j int) bool { + if ranked[i].Clusters[last] != ranked[j].Clusters[last] { + return ranked[i].Clusters[last] > ranked[j].Clusters[last] + } + return versionLess(ranked[j].Version, ranked[i].Version) + }) + other := OtherVersions{Count: len(ranked) - limit, Clusters: make([]uint64, len(axis.dates))} + for _, v := range ranked[limit:] { + for i, c := range v.Clusters { + other.Clusters[i] += c + } + } + kept := make(map[string]bool, limit) + for _, v := range ranked[:limit] { + kept[v.Version] = true + } + versions := series.Versions[:0] + for _, v := range series.Versions { + if kept[v.Version] { + versions = append(versions, v) + } + } + series.Versions = versions + series.Other = &other + } + return series +} + +// versionedHistories drops the samples recorded before the reported version was +// kept in history, so the version series spans the days it actually knows a +// version for instead of climbing out of a run of blank days. +func versionedHistories(histories map[string][]HistorySample) map[string][]HistorySample { + out := make(map[string][]HistorySample, len(histories)) + for id, history := range histories { + var kept []HistorySample + for _, sample := range history { + if sample.Version != "" { + kept = append(kept, sample) + } + } + if len(kept) > 0 { + out[id] = kept + } + } + return out +} + +// versionLess orders release strings like "3.97" and "4.40" by their numeric +// components rather than lexically, which would sort "10.02" before "9.99". A +// build suffix sits next to the release it was built from. Anything that +// doesn't parse, such as the "unknown" a client with no version compiled in +// reports, sorts first. +func versionLess(a, b string) bool { + an, aSuffix, aok := versionParts(a) + bn, bSuffix, bok := versionParts(b) + if aok != bok { + return !aok + } + if !aok { + return a < b + } + for i := 0; i < len(an) && i < len(bn); i++ { + if an[i] != bn[i] { + return an[i] < bn[i] + } + } + if len(an) != len(bn) { + return len(an) < len(bn) + } + return aSuffix < bSuffix +} + +func versionParts(v string) ([]int, string, bool) { + number, suffix, _ := strings.Cut(v, "-") + fields := strings.Split(number, ".") + parts := make([]int, 0, len(fields)) + for _, field := range fields { + n, err := strconv.Atoi(field) + if err != nil { + return nil, "", false + } + parts = append(parts, n) + } + return parts, suffix, len(parts) > 0 +} diff --git a/telemetry/server/storage/versions_test.go b/telemetry/server/storage/versions_test.go new file mode 100644 index 000000000..828770019 --- /dev/null +++ b/telemetry/server/storage/versions_test.go @@ -0,0 +1,109 @@ +package storage + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" +) + +func TestVersionSeries(t *testing.T) { + s := newPrometheusStorage(prometheus.NewRegistry()) + + // Upgraded mid-window: its band leaves the old version for the new one. + seedSamples(s, "upgraded", HistorySample{Version: "4.39"}, -4, -3) + seedSamples(s, "upgraded", HistorySample{Version: "4.40"}, -2, -1, 0) + // Reported every day on the same version. + seedSamples(s, "steady", HistorySample{Version: "4.40"}, -4, -3, -2, -1, 0) + // One day of history only: unconfirmed, so it stays out of the stack. + seedSamples(s, "oneshot", HistorySample{Version: "4.40"}, -1) + // Confirmed, but its samples predate versions being recorded. + seedSamples(s, "versionless", HistorySample{}, -9, -8) + + series := s.GetVersionSeries(10, 0) + + // The versionless days are dropped before the axis is built, so the chart + // spans the days a version is known for instead of climbing out of blanks. + if len(series.Dates) != 5 { + t.Fatalf("dates = %v, want the 5 days with versions", series.Dates) + } + if len(series.Versions) != 2 || + series.Versions[0].Version != "4.39" || series.Versions[1].Version != "4.40" { + t.Fatalf("versions = %+v, want 4.39 then 4.40", series.Versions) + } + if got := series.Versions[0].Clusters; !equal(got, []uint64{1, 1, 0, 0, 0}) { + t.Errorf("4.39 = %v, want the upgraded cluster's first two days", got) + } + if got := series.Versions[1].Clusters; !equal(got, []uint64{1, 1, 2, 2, 2}) { + t.Errorf("4.40 = %v, want steady plus upgraded from day 3", got) + } + if series.TotalClusters != 2 { + t.Errorf("total_clusters = %d, want 2", series.TotalClusters) + } + if series.Other != nil { + t.Errorf("other = %+v, want none without a limit", series.Other) + } +} + +func TestVersionSeriesHoldsForwardAndLimits(t *testing.T) { + s := newPrometheusStorage(prometheus.NewRegistry()) + + // Stopped reporting past the active window: its own days only. + seedSamples(s, "gone", HistorySample{Version: "3.97"}, -9, -8) + // Reported every day of the window. + seedSamples(s, "daily", HistorySample{Version: "4.40"}, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0) + // Reported three days ago and not since: still active, so it holds its + // version to the right edge instead of dropping out of the stack. + seedSamples(s, "lagging", HistorySample{Version: "4.30"}, -3, -2) + + series := s.GetVersionSeries(10, 0) + if len(series.Dates) != 10 { + t.Fatalf("dates = %v, want 10 days", series.Dates) + } + byVersion := map[string][]uint64{} + for _, v := range series.Versions { + byVersion[v.Version] = v.Clusters + } + if got := byVersion["3.97"]; !equal(got, []uint64{1, 1, 0, 0, 0, 0, 0, 0, 0, 0}) { + t.Errorf("3.97 = %v, want nothing after its last report", got) + } + if got := byVersion["4.30"]; !equal(got, []uint64{0, 0, 0, 0, 0, 0, 1, 1, 1, 1}) { + t.Errorf("4.30 = %v, want carried to the right edge", got) + } + if got := byVersion["4.40"]; !equal(got, []uint64{1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) { + t.Errorf("4.40 = %v, want 1 every day", got) + } + if series.TotalClusters != 2 { + t.Errorf("total_clusters = %d, want the 2 clusters still reporting", series.TotalClusters) + } + + // Past the limit, the versions the fewest clusters are on are summed into + // one band; ties on the last day keep the newer version. + series = s.GetVersionSeries(10, 1) + if len(series.Versions) != 1 || series.Versions[0].Version != "4.40" { + t.Fatalf("limited versions = %+v, want just 4.40", series.Versions) + } + if series.Other == nil || series.Other.Count != 2 { + t.Fatalf("other = %+v, want 2 versions", series.Other) + } + if !equal(series.Other.Clusters, []uint64{1, 1, 0, 0, 0, 0, 1, 1, 1, 1}) { + t.Errorf("other = %v, want 3.97+4.30 summed per day", series.Other.Clusters) + } + if series.TotalClusters != 2 { + t.Errorf("limit changed total_clusters: %d, want 2", series.TotalClusters) + } +} + +func TestVersionLess(t *testing.T) { + // Ordered oldest to newest; every pair must compare in this order. + ordered := []string{"unknown", "3.97", "4.02", "4.30", "4.30-enterprise", "9.99", "10.02"} + for i := 0; i < len(ordered); i++ { + for j := i + 1; j < len(ordered); j++ { + if !versionLess(ordered[i], ordered[j]) { + t.Errorf("versionLess(%q, %q) = false, want true", ordered[i], ordered[j]) + } + if versionLess(ordered[j], ordered[i]) { + t.Errorf("versionLess(%q, %q) = true, want false", ordered[j], ordered[i]) + } + } + } +}