perf: make duration and end time configurable

This adds a ?day=N parameter that adjusts the query range, and ?end=TIME
parameter that sets the view end time.

Addition form fields allow adjusting these settings.

Though the search box and duration/end boxes are conceptually different
adjustments, and have separate submit buttons, put them inside the same
<form> so that the browser automatically sets all query parameters as
expected. e.g., when on a single benchmark page, changing duration/end
should not change the selected benchmark.

For golang/go#48803.

Change-Id: I8ff366983c568dc0e887289a174082c248934c2d
Reviewed-on: https://go-review.googlesource.com/c/build/+/413578
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Michael Knyszek <mknyszek@google.com>
Run-TryBot: Michael Pratt <mpratt@google.com>
diff --git a/perf/app/dashboard.go b/perf/app/dashboard.go
index 0474cd0..bc6639c 100644
--- a/perf/app/dashboard.go
+++ b/perf/app/dashboard.go
@@ -15,6 +15,7 @@
 	"net/http"
 	"regexp"
 	"sort"
+	"strconv"
 	"strings"
 	"time"
 
@@ -105,7 +106,7 @@
 var errBenchmarkNotFound = errors.New("benchmark not found")
 
 // fetchNamedUnitBenchmark queries Influx for a specific name + unit benchmark.
-func fetchNamedUnitBenchmark(ctx context.Context, qc api.QueryAPI, name, unit string) (*BenchmarkJSON, error) {
+func fetchNamedUnitBenchmark(ctx context.Context, qc api.QueryAPI, start, end time.Time, name, unit string) (*BenchmarkJSON, error) {
 	if err := validateFluxString(name); err != nil {
 		return nil, fmt.Errorf("invalid benchmark name: %w", err)
 	}
@@ -115,7 +116,7 @@
 
 	query := fmt.Sprintf(`
 from(bucket: "perf")
-  |> range(start: -30d)
+  |> range(start: %s, stop: %s)
   |> filter(fn: (r) => r["_measurement"] == "benchmark-result")
   |> filter(fn: (r) => r["name"] == "%s")
   |> filter(fn: (r) => r["unit"] == "%s")
@@ -124,7 +125,7 @@
   |> filter(fn: (r) => r["goarch"] == "amd64")
   |> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
   |> yield(name: "last")
-`, name, unit)
+`, start.Format(time.RFC3339), end.Format(time.RFC3339), name, unit)
 
 	res, err := qc.Query(ctx, query)
 	if err != nil {
@@ -145,7 +146,7 @@
 }
 
 // fetchDefaultBenchmarks queries Influx for the default benchmark set.
-func fetchDefaultBenchmarks(ctx context.Context, qc api.QueryAPI) ([]*BenchmarkJSON, error) {
+func fetchDefaultBenchmarks(ctx context.Context, qc api.QueryAPI, start, end time.Time) ([]*BenchmarkJSON, error) {
 	// Keep benchmarks with the same name grouped together, which is
 	// assumed by the JS.
 	benchmarks := []struct{ name, unit string }{
@@ -205,7 +206,7 @@
 
 	ret := make([]*BenchmarkJSON, 0, len(benchmarks))
 	for _, bench := range benchmarks {
-		b, err := fetchNamedUnitBenchmark(ctx, qc, bench.name, bench.unit)
+		b, err := fetchNamedUnitBenchmark(ctx, qc, start, end, bench.name, bench.unit)
 		if err != nil {
 			return nil, fmt.Errorf("error fetching benchmark %s/%s: %w", bench.name, bench.unit, err)
 		}
@@ -217,14 +218,14 @@
 
 // fetchNamedBenchmark queries Influx for all benchmark results with the passed
 // name (for all units).
-func fetchNamedBenchmark(ctx context.Context, qc api.QueryAPI, name string) ([]*BenchmarkJSON, error) {
+func fetchNamedBenchmark(ctx context.Context, qc api.QueryAPI, start, end time.Time, name string) ([]*BenchmarkJSON, error) {
 	if err := validateFluxString(name); err != nil {
 		return nil, fmt.Errorf("invalid benchmark name: %w", err)
 	}
 
 	query := fmt.Sprintf(`
 from(bucket: "perf")
-  |> range(start: -30d)
+  |> range(start: %s, stop: %s)
   |> filter(fn: (r) => r["_measurement"] == "benchmark-result")
   |> filter(fn: (r) => r["name"] == "%s")
   |> filter(fn: (r) => r["branch"] == "master")
@@ -232,7 +233,7 @@
   |> filter(fn: (r) => r["goarch"] == "amd64")
   |> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
   |> yield(name: "last")
-`, name)
+`, start.Format(time.RFC3339), end.Format(time.RFC3339), name)
 
 	res, err := qc.Query(ctx, query)
 	if err != nil {
@@ -250,17 +251,17 @@
 }
 
 // fetchAllBenchmarks queries Influx for all benchmark results.
-func fetchAllBenchmarks(ctx context.Context, qc api.QueryAPI) ([]*BenchmarkJSON, error) {
-	const query = `
+func fetchAllBenchmarks(ctx context.Context, qc api.QueryAPI, start, end time.Time) ([]*BenchmarkJSON, error) {
+	query := fmt.Sprintf(`
 from(bucket: "perf")
-  |> range(start: -30d)
+  |> range(start: %s, stop: %s)
   |> filter(fn: (r) => r["_measurement"] == "benchmark-result")
   |> filter(fn: (r) => r["branch"] == "master")
   |> filter(fn: (r) => r["goos"] == "linux")
   |> filter(fn: (r) => r["goarch"] == "amd64")
   |> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
   |> yield(name: "last")
-`
+`, start.Format(time.RFC3339), end.Format(time.RFC3339))
 
 	res, err := qc.Query(ctx, query)
 	if err != nil {
@@ -340,6 +341,11 @@
 	return w.w.Write(b)
 }
 
+const (
+	defaultDays = 30
+	maxDays     = 366
+)
+
 // search handles /dashboard/data.json.
 //
 // TODO(prattmic): Consider caching Influx results in-memory for a few mintures
@@ -347,9 +353,44 @@
 func (a *App) dashboardData(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
 
-	start := time.Now()
+	days := uint64(defaultDays)
+	dayParam := r.FormValue("days")
+	if dayParam != "" {
+		var err error
+		days, err = strconv.ParseUint(dayParam, 10, 32)
+		if err != nil {
+			log.Printf("Error parsing days %q: %v", dayParam, err)
+			http.Error(w, fmt.Sprintf("day parameter must be a positive integer less than or equal to %d", maxDays), http.StatusBadRequest)
+			return
+		}
+		if days == 0 || days > maxDays {
+			log.Printf("days %d too large", days)
+			http.Error(w, fmt.Sprintf("day parameter must be a positive integer less than or equal to %d", maxDays), http.StatusBadRequest)
+			return
+		}
+	}
+
+	end := time.Now()
+	endParam := r.FormValue("end")
+	if endParam != "" {
+		var err error
+		// Quirk: Browsers don't have an easy built-in way to deal with
+		// timezone in input boxes. The datetime input type yields a
+		// string in this form, with no timezone (either local or UTC).
+		// Thus, we just treat this as UTC.
+		end, err = time.Parse("2006-01-02T15:04", endParam)
+		if err != nil {
+			log.Printf("Error parsing end %q: %v", endParam, err)
+			http.Error(w, "end parameter must be a timestamp similar to RFC3339 without a time zone, like 2000-12-31T15:00", http.StatusBadRequest)
+			return
+		}
+	}
+
+	start := end.Add(-24*time.Hour*time.Duration(days))
+
+	methStart := time.Now()
 	defer func() {
-		log.Printf("Dashboard total query time: %s", time.Since(start))
+		log.Printf("Dashboard total query time: %s", time.Since(methStart))
 	}()
 
 	ifxc, err := a.influxClient(ctx)
@@ -365,11 +406,11 @@
 	benchmark := r.FormValue("benchmark")
 	var benchmarks []*BenchmarkJSON
 	if benchmark == "" {
-		benchmarks, err = fetchDefaultBenchmarks(ctx, qc)
+		benchmarks, err = fetchDefaultBenchmarks(ctx, qc, start, end)
 	} else if benchmark == "all" {
-		benchmarks, err = fetchAllBenchmarks(ctx, qc)
+		benchmarks, err = fetchAllBenchmarks(ctx, qc, start, end)
 	} else {
-		benchmarks, err = fetchNamedBenchmark(ctx, qc, benchmark)
+		benchmarks, err = fetchNamedBenchmark(ctx, qc, start, end, benchmark)
 	}
 	if err == errBenchmarkNotFound {
 		log.Printf("Benchmark not found: %q", benchmark)
diff --git a/perf/app/dashboard/index.html b/perf/app/dashboard/index.html
index d7c0166..fd2c648 100644
--- a/perf/app/dashboard/index.html
+++ b/perf/app/dashboard/index.html
@@ -29,17 +29,26 @@
 </header>
 
 <nav class="Dashboard-controls">
-	<ul>
-		<li>
-			<form autocomplete="off" action="./">
+	<form autocomplete="off" action="./">
+		<ul>
+			<li>
 				<div class="Dashboard-search">
-					<input id="benchmarkInput" type="text" name="benchmark" placeholder="Type benchmark name...">
+					<input id="benchmark-input" type="text" name="benchmark" placeholder="Type benchmark name..." />
 				</div>
+				<input type="submit" />
+			</li>
+			<li><a href="?benchmark=all">All benchmarks</a></li>
+			<span class="left-separator"></span>
+			<li>
+				Duration (days):
+				<div class="Dashboard-duration">
+					<input id="days-input" type="number" name="days" value="30" />
+				</div>
+				End (UTC): <input id="end-input" type="datetime-local" name="end" />
 				<input type="submit">
-			</form>
-		</li>
-		<li><a href="?benchmark=all">All benchmarks</a></li>
-	</ul>
+			</li>
+		</ul>
+	</form>
 </nav>
 
 <div class="Dashboard-documentation">
@@ -105,7 +114,7 @@
 	}
 }
 
-function failure(name) {
+function failure(name, response) {
 	let dashboard = document.getElementById("dashboard");
 
 	removeLoadingMessage();
@@ -114,17 +123,56 @@
 	title.classList.add("Dashboard-title");
 	title.innerHTML = "Benchmark \"" + name + "\" not found.";
 	dashboard.appendChild(title);
+
+	let message = document.createElement("p");
+	message.classList.add("Dashboard-documentation");
+	response.text().then(function(error) {
+		message.innerHTML = error;
+	});
+	dashboard.appendChild(message);
 }
 
-let benchmark = (new URLSearchParams(window.location.search)).get('benchmark');
-let dataURL = './data.json';
-if (benchmark) {
-	dataURL += '?benchmark=' + benchmark;
+// Fill search boxes from query params.
+function prefillSearch() {
+	let params = new URLSearchParams(window.location.search);
+
+	let benchmark = params.get('benchmark');
+	if (benchmark) {
+		let input = document.getElementById('benchmark-input');
+		input.value = benchmark;
+	}
+
+	let days = params.get('days');
+	if (days) {
+		let input = document.getElementById('days-input');
+		input.value = days;
+	}
+
+	let end = params.get('end');
+	let input = document.getElementById('end-input');
+	if (end) {
+		input.value = end;
+	} else {
+		// Default to now.
+		let now = new Date();
+
+		// toISOString always uses UTC, then we just chop off the end
+		// of string to get the datetime-local format of
+		// 2000-12-31T15:00.
+		//
+		// Yes, this is really the suggested approach...
+		input.value = now.toISOString().slice(0, 16);
+	}
 }
+prefillSearch()
+
+// Fetch content.
+let benchmark = (new URLSearchParams(window.location.search)).get('benchmark');
+let dataURL = './data.json' + window.location.search;  // Pass through all URL params.
 fetch(dataURL)
 	.then(response => {
 		if (!response.ok) {
-			failure(benchmark);
+			failure(benchmark, response);
 			throw new Error("Data fetch failed");
 		}
 		return response.json();
diff --git a/perf/app/dashboard/static/style.css b/perf/app/dashboard/static/style.css
index d832d87..1db9a4f 100644
--- a/perf/app/dashboard/static/style.css
+++ b/perf/app/dashboard/static/style.css
@@ -77,6 +77,12 @@
   padding: 10px;
   text-decoration: none;
 }
+nav .left-separator {
+  border-left: 2px solid #b7b7b7;
+  padding-left: 10px;
+  padding-top: 10px;
+  padding-bottom: 10px;
+}
 
 .Dashboard {
   margin: 0;
@@ -108,7 +114,6 @@
   font-size: 16px;
 }
 input[type=text] {
-  background-color: #f4f4f4;
   width: 100%;
 }
 input[type=submit] {
@@ -123,6 +128,15 @@
   padding: 10px;
   text-decoration: none;
 }
+
+.Dashboard-duration {
+  display: inline-block;
+  width: 4em;
+}
+input[type=number] {
+  width: 100%;
+}
+
 .Dashboard-grid {
   display: flex;
   flex-direction: row;