analysis/appengine/template: improve label display

The display now shows the top N labels, and shows common labels
separately. Each label is a link that filters the results based on
that label.

Also fixes a typo and removes a harmless double Close.

Change-Id: I25b93c7bbfd584ad345c4508e64cd5db73298745
Reviewed-on: https://go-review.googlesource.com/35675
Reviewed-by: Russ Cox <rsc@golang.org>
diff --git a/analysis/app/compare.go b/analysis/app/compare.go
index 073ba5c..4428a3d 100644
--- a/analysis/app/compare.go
+++ b/analysis/app/compare.go
@@ -10,6 +10,7 @@
 	"io/ioutil"
 	"net/http"
 	"sort"
+	"strings"
 
 	"golang.org/x/perf/analysis/internal/benchstat"
 	"golang.org/x/perf/storage/benchfmt"
@@ -20,21 +21,30 @@
 	// Raw list of results.
 	results []*benchfmt.Result
 	// LabelValues is the count of results found with each distinct (key, value) pair found in labels.
-	LabelValues map[string]map[string]int
+	// A value of "" counts results missing that key.
+	LabelValues map[string]valueSet
 }
 
 // add adds res to the resultGroup.
 func (g *resultGroup) add(res *benchfmt.Result) {
 	g.results = append(g.results, res)
 	if g.LabelValues == nil {
-		g.LabelValues = make(map[string]map[string]int)
+		g.LabelValues = make(map[string]valueSet)
 	}
 	for k, v := range res.Labels {
 		if g.LabelValues[k] == nil {
-			g.LabelValues[k] = make(map[string]int)
+			g.LabelValues[k] = make(valueSet)
+			if len(g.results) > 1 {
+				g.LabelValues[k][""] = len(g.results) - 1
+			}
 		}
 		g.LabelValues[k][v]++
 	}
+	for k := range g.LabelValues {
+		if res.Labels[k] == "" {
+			g.LabelValues[k][""]++
+		}
+	}
 }
 
 // splitOn returns a new set of groups sharing a common value for key.
@@ -58,6 +68,57 @@
 	return out
 }
 
+// valueSet is a set of values and the number of results with each value.
+type valueSet map[string]int
+
+// valueCount and byCount are used for sorting a valueSet
+type valueCount struct {
+	Value string
+	Count int
+}
+type byCount []valueCount
+
+func (s byCount) Len() int      { return len(s) }
+func (s byCount) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
+
+func (s byCount) Less(i, j int) bool {
+	if s[i].Count != s[j].Count {
+		return s[i].Count > s[j].Count
+	}
+	return s[i].Value < s[j].Value
+}
+
+// TopN returns a slice containing n valueCount entries, and if any labels were omitted, an extra entry with value "…".
+func (vs valueSet) TopN(n int) []valueCount {
+	var s []valueCount
+	var total int
+	for v, count := range vs {
+		s = append(s, valueCount{v, count})
+		total += count
+	}
+	sort.Sort(byCount(s))
+	out := s
+	if len(out) > n {
+		out = s[:n]
+	}
+	if len(out) < len(s) {
+		var outTotal int
+		for _, vc := range out {
+			outTotal += vc.Count
+		}
+		out = append(out, valueCount{"…", total - outTotal})
+	}
+	return out
+}
+
+// addToQuery returns a new query string with add applied as a filter.
+func addToQuery(query, add string) string {
+	if strings.Contains(query, "|") {
+		return add + " " + query
+	}
+	return add + " | " + query
+}
+
 // compare handles queries that require comparison of the groups in the query.
 func (a *App) compare(w http.ResponseWriter, r *http.Request) {
 	if err := r.ParseForm(); err != nil {
@@ -73,7 +134,9 @@
 		return
 	}
 
-	t, err := template.New("main").Parse(string(tmpl))
+	t, err := template.New("main").Funcs(template.FuncMap{
+		"addToQuery": addToQuery,
+	}).Parse(string(tmpl))
 	if err != nil {
 		http.Error(w, err.Error(), 500)
 		return
@@ -89,11 +152,12 @@
 }
 
 type compareData struct {
-	Q         string
-	Error     string
-	Benchstat template.HTML
-	Groups    []*resultGroup
-	Labels    map[string]bool
+	Q            string
+	Error        string
+	Benchstat    template.HTML
+	Groups       []*resultGroup
+	Labels       map[string]bool
+	CommonLabels benchfmt.Labels
 }
 
 func (a *App) compareQuery(q string) *compareData {
@@ -107,7 +171,6 @@
 	for _, qPart := range queries {
 		group := &resultGroup{}
 		res := a.StorageClient.Query(qPart)
-		defer res.Close() // TODO: Should happen each time through the loop
 		for res.Next() {
 			group.add(res.Result())
 			found++
@@ -128,7 +191,7 @@
 		return &compareData{
 			Q:     q,
 			Error: "No results matched the query string.",
-		}, nil
+		}
 	}
 
 	// Attempt to automatically split results.
@@ -150,18 +213,42 @@
 		HTML: true,
 	})
 
-	// Render template.
+	// Prepare struct for template.
 	labels := make(map[string]bool)
+	// commonLabels are the key: value of every label that has an
+	// identical value on every result.
+	commonLabels := make(benchfmt.Labels)
+	// Scan the first group for common labels.
+	for k, vs := range groups[0].LabelValues {
+		if len(vs) == 1 {
+			for v := range vs {
+				commonLabels[k] = v
+			}
+		}
+	}
+	// Remove any labels not common in later groups.
+	for _, g := range groups[1:] {
+		for k, v := range commonLabels {
+			if len(g.LabelValues[k]) != 1 || g.LabelValues[k][v] == 0 {
+				delete(commonLabels, k)
+			}
+		}
+	}
+	// List all labels present and not in commonLabels.
 	for _, g := range groups {
 		for k := range g.LabelValues {
+			if commonLabels[k] != "" {
+				continue
+			}
 			labels[k] = true
 		}
 	}
 	data := &compareData{
-		Q:         q,
-		Benchstat: template.HTML(buf.String()),
-		Groups:    groups,
-		Labels:    labels,
+		Q:            q,
+		Benchstat:    template.HTML(buf.String()),
+		Groups:       groups,
+		Labels:       labels,
+		CommonLabels: commonLabels,
 	}
 	return data
 }
diff --git a/analysis/app/compare_test.go b/analysis/app/compare_test.go
index 301fd0e..f7f92e9 100644
--- a/analysis/app/compare_test.go
+++ b/analysis/app/compare_test.go
@@ -34,7 +34,7 @@
 	if !reflect.DeepEqual(g.results, results) {
 		t.Errorf("g.results = %#v, want %#v", g.results, results)
 	}
-	if want := map[string]map[string]int{"key": {"value": 1, "value2": 1}}; !reflect.DeepEqual(g.LabelValues, want) {
+	if want := map[string]valueSet{"key": {"value": 1, "value2": 1}}; !reflect.DeepEqual(g.LabelValues, want) {
 		t.Errorf("g.LabelValues = %#v, want %#v", g.LabelValues, want)
 	}
 	groups := g.splitOn("key")
@@ -89,9 +89,9 @@
 
 	for _, q := range []string{"one vs two", "onetwo"} {
 		t.Run(q, func(t *testing.T) {
-			data, err := a.compareQuery(q)
-			if err != nil {
-				t.Fatalf("compareQuery failed: %v", err)
+			data := a.compareQuery(q)
+			if data.Error != "" {
+				t.Fatalf("compareQuery failed: %s", data.Error)
 			}
 			if have := data.Q; have != q {
 				t.Errorf("Q = %q, want %q", have, q)
@@ -102,9 +102,12 @@
 			if len(data.Benchstat) == 0 {
 				t.Error("len(Benchstat) = 0, want >0")
 			}
-			if want := map[string]bool{"upload": true, "upload-part": true, "label": true}; !reflect.DeepEqual(data.Labels, want) {
+			if want := map[string]bool{"upload-part": true, "label": true}; !reflect.DeepEqual(data.Labels, want) {
 				t.Errorf("Labels = %#v, want %#v", data.Labels, want)
 			}
+			if want := (benchfmt.Labels{"upload": "1"}); !reflect.DeepEqual(data.CommonLabels, want) {
+				t.Errorf("CommonLabels = %#v, want %#v", data.CommonLabels, want)
+			}
 		})
 	}
 }
diff --git a/analysis/appengine/template/compare.html b/analysis/appengine/template/compare.html
index 741ca6e..2725d72 100644
--- a/analysis/appengine/template/compare.html
+++ b/analysis/appengine/template/compare.html
@@ -2,46 +2,137 @@
 <html>
   <head>
     <title>Performance Result Comparison</title>
+    <style type="text/css">
+#header h1 {
+  display: inline;
+}
+#search {
+  padding: 1em .5em;
+  width: 100%;
+}
+input[type="text"] {
+  font-size: 100%;
+}
+#results {
+  border-top: 1px solid black;
+}
+tr.diff td {
+  font-size: 80%;
+  font-family: sans-serif;
+  vertical-align: top;
+}
+th.label {
+  text-align: left;
+  vertical-align: top;
+}
+td.count {
+  text-align: right;
+}
+#labels {
+  float: left;
+  margin-right: 1em;
+  border-right: 1px solid black;
+  border-collapse: collapse;
+  vertical-align: top;
+}
+#labels tbody {
+  border-collapse: collapse;
+  border-bottom: 1px solid black;
+}
+table.benchstat {
+  border-collapse: collapse;
+}
+table.benchstat td, table.benchstat th {
+  padding-right: 2px;
+  padding-bottom: 2px;
+}
+#labels > tbody > tr:last-child th, #labels > tbody > tr:last-child td {
+  padding-bottom: 1em;
+}
+#labels tbody tr:first-child th, #benchstat {
+  padding-top: 1em;
+}
+#labels tbody.diff tr:first-child th {
+  padding-top: 1em;
+  border-collapse: collapse;
+  border-top: 1px solid black;
+}
+#labels .diff {
+  padding-bottom: 1em;
+}
+    </style>
   </head>
   <body>
-    <div>
+    <div id="header">
+      <h1>Go Performance Dashboard</h1>
+      <a href="/">about</a>
+    </div>
+    <div id="search">
       <form action="/search">
         <input type="text" name="q" value="{{.Q}}" size="120">
         <input type="submit" value="Search">
       </form>
     </div>
-    {{with .Error}}
-    <p>{{.}}</p>
-    {{else}}
-      <div>
-        {{.Benchstat}}
-      </div>
-      <table>
-        <tr>
-          <th>label</th>
-          {{range $index, $group := .Groups}}
-          <th>
-            #{{$index}}
-          </th>
-          {{end}}
-        </tr>
-        {{range $label, $exists := .Labels}}
-        <tr>
-          <th>{{$label}}</th>
-          {{range $.Groups}}
-          <td>
-            {{with index .LabelValues $label}}
-              [
-              {{range $value, $exists := .}}
-                {{printf "%q" $value}}
+    <div id="results">
+      {{with .Error}}
+      <p>{{.}}</p>
+      {{else}}
+        <table id="labels">
+          {{with .CommonLabels}}
+            <tbody>
+              <tr>
+                <th>label</th><th>common value</th>
+              </tr>
+              {{range $label, $value := .}}
+                <tr>
+                  <th class="label">{{$label}}</th><td>{{$value}}</td>
+                </tr>
               {{end}}
-              ]
-            {{end}}
-          </td>
+            </tbody>
           {{end}}
-        </tr>
-        {{end}}
-      </table>
-    {{end}}
+          <tbody class="diff">
+            <tr>
+              <th>label</th>
+              <th>values</th>
+            </tr>
+            {{range $label, $exists := .Labels}}
+            <tr class="diff">
+              <th class="label">{{$label}}</th>
+              <td>
+                {{range $index, $group := $.Groups}}
+                Query {{$index}}: 
+                <table>
+                  {{with index $group.LabelValues $label}}
+                    {{range .TopN 4}}
+                      <tr>
+                        <td class="count">
+                          {{.Count}}
+                        </td>
+                        <td>
+                          {{if eq .Value ""}}
+                            missing
+                          {{else if eq .Value "…"}}
+                            {{.Value}}
+                          {{else}}
+                            <a href="/search?q={{addToQuery $.Q (printf "%s:%s" $label .Value)}}">
+                              {{printf "%q" .Value}}
+                            </a>
+                          {{end}}
+                        </td>
+                      </tr>
+                    {{end}}
+                  {{end}}
+                </table>
+              {{end}}
+              </td>
+            </tr>
+            {{end}}
+            </tbody>
+        </table>
+        <div id="benchstat">
+          {{.Benchstat}}
+        </div>
+      {{end}}
+      </div>
   </body>
 </html>