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]) + } + } + } +}