cmd/coordinator: render partial build dashboard

This change adds a build dashboard handler to the Coordinator. As part
of the effort to remove the app/appengine build dashboard, this moves a
large part of the template and logic into cmd/coordinator.

As part of this change, cmd/coordinator/internal/dashboard has been
created. I originally developed this in the main package, but the main
package is very crowded in the coordinator. Giving the dashboard its own
package also made testing easier.

Currently, this implementation only supports rendering part of the build
dashboard for the Go repository on master. It does not yet link to test
logs, and only shows successful state.

Updates golang/go#34744

Change-Id: I6ffe064b9fc5e4a3271eadfd5ac45d5baf4ebd37
Reviewed-on: https://go-review.googlesource.com/c/build/+/221920
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/buildenv/envs.go b/buildenv/envs.go
index 89ba60b..f80a186 100644
--- a/buildenv/envs.go
+++ b/buildenv/envs.go
@@ -54,11 +54,15 @@
 	// This field may be overridden as necessary without impacting other fields.
 	ProjectName string
 
-	// ProjectNumber is the GCP project's number, as visible in the admin console.
-	// This is used for things such as constructing the "email" of the default
-	// service account.
+	// ProjectNumber is the GCP build infrastructure project's number, as visible
+	// in the admin console. This is used for things such as constructing the
+	// "email" of the default service account.
 	ProjectNumber int64
 
+	// The GCP project name for the Go project, where build status is stored.
+	// This field may be overridden as necessary without impacting other fields.
+	GoProjectName string
+
 	// The IsProd flag indicates whether production functionality should be
 	// enabled. When true, GCE and Kubernetes builders are enabled and the
 	// coordinator serves on 443. Otherwise, GCE and Kubernetes builders are
@@ -231,6 +235,7 @@
 var Staging = &Environment{
 	ProjectName:           "go-dashboard-dev",
 	ProjectNumber:         302018677728,
+	GoProjectName:         "go-dashboard-dev",
 	IsProd:                true,
 	ControlZone:           "us-central1-f",
 	VMZones:               []string{"us-central1-a", "us-central1-b", "us-central1-c", "us-central1-f"},
@@ -263,6 +268,7 @@
 var Production = &Environment{
 	ProjectName:           "symbolic-datum-552",
 	ProjectNumber:         872405196845,
+	GoProjectName:         "golang-org",
 	IsProd:                true,
 	ControlZone:           "us-central1-f",
 	VMZones:               []string{"us-central1-a", "us-central1-b", "us-central1-c", "us-central1-f"},
@@ -292,8 +298,9 @@
 }
 
 var Development = &Environment{
-	IsProd:   false,
-	StaticIP: "127.0.0.1",
+	GoProjectName: "golang-org",
+	IsProd:        false,
+	StaticIP:      "127.0.0.1",
 }
 
 // possibleEnvs enumerate the known buildenv.Environment definitions.
diff --git a/cmd/coordinator/Dockerfile b/cmd/coordinator/Dockerfile
index 03cfd87..ce4adc4 100644
--- a/cmd/coordinator/Dockerfile
+++ b/cmd/coordinator/Dockerfile
@@ -86,6 +86,8 @@
 	&& rm -rf /var/lib/apt/lists/*
 
 
+COPY --from=build /go/src/golang.org/x/build/cmd/coordinator/internal/dashboard/dashboard.html /dashboard.html
+COPY --from=build /go/src/golang.org/x/build/cmd/coordinator/style.css /style.css
 COPY --from=build /go/bin/coordinator /
 COPY --from=build_drawterm /usr/local/bin/drawterm /usr/local/bin/
 
diff --git a/cmd/coordinator/coordinator.go b/cmd/coordinator/coordinator.go
index ae3b025..4255b7e6 100644
--- a/cmd/coordinator/coordinator.go
+++ b/cmd/coordinator/coordinator.go
@@ -45,6 +45,7 @@
 	"unicode"
 
 	"go4.org/syncutil"
+	builddash "golang.org/x/build/cmd/coordinator/internal/dashboard"
 	"golang.org/x/build/cmd/coordinator/protos"
 	"google.golang.org/grpc"
 	grpc4 "grpc.go4.org"
@@ -305,6 +306,11 @@
 	}
 	maintnerClient = apipb.NewMaintnerServiceClient(cc)
 
+	if err := loadStatic(); err != nil {
+		log.Printf("Failed to load static resources: %v", err)
+	}
+
+	dh := &builddash.Handler{Datastore: goDSClient, Maintner: maintnerClient}
 	gs := &gRPCServer{dashboardURL: "https://build.golang.org"}
 	protos.RegisterCoordinatorServer(grpcServer, gs)
 	http.HandleFunc("/", handleStatus)
@@ -319,6 +325,7 @@
 	http.HandleFunc("/try.json", serveTryStatus(true))
 	http.HandleFunc("/status/reverse.json", reversePool.ServeReverseStatusJSON)
 	http.HandleFunc("/status/post-submit-active.json", handlePostSubmitActiveJSON)
+	http.Handle("/dashboard", dh)
 	http.Handle("/buildlet/create", requireBuildletProxyAuth(http.HandlerFunc(handleBuildletCreate)))
 	http.Handle("/buildlet/list", requireBuildletProxyAuth(http.HandlerFunc(handleBuildletList)))
 	go func() {
diff --git a/cmd/coordinator/gce.go b/cmd/coordinator/gce.go
index 228dee5..dac982d 100644
--- a/cmd/coordinator/gce.go
+++ b/cmd/coordinator/gce.go
@@ -42,7 +42,7 @@
 	"golang.org/x/build/internal/lru"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2/google"
-	compute "google.golang.org/api/compute/v1"
+	"google.golang.org/api/compute/v1"
 	"google.golang.org/api/googleapi"
 )
 
@@ -63,7 +63,10 @@
 var (
 	buildEnv *buildenv.Environment
 
-	dsClient       *datastore.Client
+	// dsClient is a datastore client for the build project (symbolic-datum-552), where build progress is stored.
+	dsClient *datastore.Client
+	// goDSClient is a datastore client for golang-org, where build status is stored.
+	goDSClient     *datastore.Client
 	computeService *compute.Service
 	gcpCreds       *google.Credentials
 	errTryDeps     error // non-nil if try bots are disabled
@@ -100,7 +103,7 @@
 	}
 
 	buildEnv = buildenv.ByProjectID(*buildEnvName)
-	inStaging = (buildEnv == buildenv.Staging)
+	inStaging = buildEnv == buildenv.Staging
 
 	// If running on GCE, override the zone and static IP, and check service account permissions.
 	if metadata.OnGCE() {
@@ -153,9 +156,17 @@
 	dsClient, err = datastore.NewClient(ctx, buildEnv.ProjectName)
 	if err != nil {
 		if *mode == "dev" {
-			log.Printf("Error creating datastore client: %v", err)
+			log.Printf("Error creating datastore client for %q: %v", buildEnv.ProjectName, err)
 		} else {
-			log.Fatalf("Error creating datastore client: %v", err)
+			log.Fatalf("Error creating datastore client for %q: %v", buildEnv.ProjectName, err)
+		}
+	}
+	goDSClient, err = datastore.NewClient(ctx, buildEnv.GoProjectName)
+	if err != nil {
+		if *mode == "dev" {
+			log.Printf("Error creating datastore client for %q: %v", buildEnv.GoProjectName, err)
+		} else {
+			log.Fatalf("Error creating datastore client for %q: %v", buildEnv.GoProjectName, err)
 		}
 	}
 
diff --git a/cmd/coordinator/internal/dashboard/dashboard.html b/cmd/coordinator/internal/dashboard/dashboard.html
new file mode 100644
index 0000000..1d9074a
--- /dev/null
+++ b/cmd/coordinator/internal/dashboard/dashboard.html
@@ -0,0 +1,188 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <title>{{$.Dashboard.Name}} Build Dashboard</title>
+  <link rel="stylesheet" href="/style.css"/>
+  <script async>
+    let showUnsupported = window.location.hash.substr(1) !== 'short';
+
+    function redraw() {
+      showUnsupported = !document.querySelector('#showshort').checked;
+      document.querySelectorAll('.unsupported').forEach(el => {
+        el.hidden = !showUnsupported;
+      });
+      window.location.hash = showUnsupported ? '' : 'short';
+      document.querySelectorAll('.Build-builderOS').forEach(el => {
+        el.setAttribute(
+          'colspan',
+          el.getAttribute(
+            showUnsupported ? 'data-archs' : 'data-firstClassArchs'
+          )
+        );
+      });
+      document.querySelectorAll('.Build-osColumn').forEach(el => {
+        el.setAttribute(
+          'span',
+          el.getAttribute(
+            showUnsupported ? 'data-archs' : 'data-firstClassArchs'
+          )
+        );
+      });
+    }
+
+    window.addEventListener('load', () => {
+      document.querySelector('#showshort').checked = !showUnsupported;
+      document.querySelector('#showshort').addEventListener('change', redraw);
+      redraw();
+    });
+  </script>
+</head>
+
+<body class="Dashboard">
+<header class="Dashboard-topbar">
+<h1>Go Dashboard</h1>
+</header>
+
+<form action="../.." method="GET">
+<input type="hidden" name="repo" value="{{.Package.Path}}"/>
+<nav class="Dashboard-controls">
+  {{if not (eq .Branch "")}}
+    <label>
+      <select name="branch" onchange="this.form.submit()">
+          {{range $.Branches}}
+            <option value="{{.}}" {{if eq $.Branch .}} selected{{end}}>
+                {{.}}
+            </option>
+          {{end}}
+      </select>
+    </label>
+  {{end}}
+  <label>
+    <input type="checkbox" id="showshort"/>
+    show only
+    <a href="http://golang.org/wiki/PortingPolicy">first-class ports</a>
+  </label>
+</nav>
+</form>
+<h2 class="Dashboard-packageName">{{$.Package.Name}}</h2>
+
+<div class="page Build-scrollTable">
+{{if $.Commits}}
+  <table class="Build">
+    <colgroup class="col-hash" {{if $.Package.Path}} span="2" {{end}}></colgroup>
+    <colgroup class="col-user"></colgroup>
+    <colgroup class="col-time"></colgroup>
+    <colgroup class="Build-descriptionColumn col-desc"></colgroup>
+      {{range $.Builders}}
+        <colgroup class="Build-osColumn col-result{{if .Unsupported}} unsupported{{end}}" span="{{.Archs | len}}"
+                  data-archs="{{.Archs | len}}" data-firstClassArchs="{{.FirstClassArchs | len}}"></colgroup>
+      {{end}}
+    <tr class="Build-builderOSRow">
+      {{if $.Package.Path}}
+        <th colspan="2">revision</th>
+      {{else}}
+        <th>&nbsp;</th>
+      {{end}}
+      <th></th>
+      <th></th>
+      <th></th>
+      {{range $.Builders}}
+        <th class="Build-builderOS{{if not .FirstClass}} unsupported{{end}}" colspan="{{.Archs | len}}"
+            data-archs="{{.Archs | len}}" data-firstClassArchs="{{.FirstClassArchs | len}}">
+            {{.OS}}
+        </th>
+      {{end}}
+    </tr>
+
+    <tr class="Build-builderArchRow">
+      {{if $.Package.Path}}
+        <th class="result arch">repo</th>
+        <th class="result arch">{{$.Dashboard.Name}}</th>
+      {{else}}
+        <th>&nbsp;</th>
+      {{end}}
+      <th></th>
+      <th></th>
+      <th></th>
+      {{range $.Builders}}
+        {{range.Archs}}
+          <th class="result arch{{if not (.FirstClass)}} unsupported{{end}}" title="{{.Name}}">
+              {{.Arch}}
+          </th>
+        {{end}}
+      {{end}}
+    </tr>
+
+    <tr class="Build-builderTagRow">
+      <th {{if $.Package.Path}}colspan="2" {{end}}>&nbsp;</th>
+      <th></th>
+      <th></th>
+      <th></th>
+      {{range $.Builders}}
+        {{range.Archs}}
+          <th class="Build-resultArch result arch{{if not (.FirstClass)}} unsupported{{end}}" title="{{.Name}}">
+              {{.Tag}}
+          </th>
+        {{end}}
+      {{end}}
+    </tr>
+    {{range $c := $.Commits}}
+      <tr class="commit">
+        <td class="hash">
+          <span class="ShortHash">
+            <a href="https://go-review.googlesource.com/q/{{$c.Hash}}">
+              {{$c.Hash}}
+            </a>
+          </span>
+        </td>
+        <td class="Build-user" title="{{$c.User}}">{{$c.ShortUser}}</td>
+        <td class="Build-commitTime">
+          {{$c.Time}}
+        </td>
+        <td class="Build-desc desc" title="{{$c.Desc}}">{{$c.Desc}}</td>
+        {{range $b := $.Builders}}
+          {{range $a := .Archs}}
+            <td class="{{if not $a.FirstClass}} unsupported{{end}}" data-builder="{{$a.Name}}">
+              {{with $c.ResultForBuilder $a.Name}}
+                  {{if .OK}} ok {{end}}
+              {{end}}
+            </td>
+          {{end}}
+        {{end}}
+      </tr>
+    {{end}}
+  </table>
+
+  {{with $.Pagination}}
+    <div class="paginate">
+      <nav>
+        {{if .HasPrev}}
+          <a href="?repo={{$.Package.Path}}&page={{.Prev}}&branch={{$.Branch}}">
+            newer
+          </a>
+        {{else}}
+          newer
+        {{end}}
+        {{if .Next}}
+          <a href="?repo={{$.Package.Path}}&page={{.Next}}branch={{$.Branch}}">
+            older
+          </a>
+        {{else}}
+          older
+        {{end}}
+        {{if .HasPrev}}
+          <a href="?branch={{$.Branch}}">
+            latest
+          </a>
+        {{else}}
+          latest
+        {{end}}
+      </nav>
+    </div>
+  {{end}}
+{{else}}
+  <p>No commits to display. Hm.</p>
+{{end}}
+</div>
+</body>
+</html>
diff --git a/cmd/coordinator/internal/dashboard/datastore.go b/cmd/coordinator/internal/dashboard/datastore.go
new file mode 100644
index 0000000..2658d94
--- /dev/null
+++ b/cmd/coordinator/internal/dashboard/datastore.go
@@ -0,0 +1,44 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// +build go1.13
+// +build linux darwin
+
+package dashboard
+
+import (
+	"context"
+	"log"
+
+	"cloud.google.com/go/datastore"
+)
+
+// getDatastoreResults populates result data on commits, fetched from Datastore.
+func getDatastoreResults(ctx context.Context, cl *datastore.Client, commits []*commit, pkg string) {
+	var keys []*datastore.Key
+	for _, c := range commits {
+		pkey := datastore.NameKey("Package", pkg, nil)
+		pkey.Namespace = "Git"
+		key := datastore.NameKey("Commit", "|"+c.Hash, pkey)
+		key.Namespace = "Git"
+		keys = append(keys, key)
+	}
+	out := make([]*Commit, len(keys))
+	if err := cl.GetMulti(ctx, keys, out); err != nil {
+		log.Printf("getResults: error fetching %d results: %v", len(keys), err)
+		return
+	}
+	hashOut := make(map[string]*Commit)
+	for _, o := range out {
+		if o != nil && o.Hash != "" {
+			hashOut[o.Hash] = o
+		}
+	}
+	for _, c := range commits {
+		if result, ok := hashOut[c.Hash]; ok {
+			c.ResultData = result.ResultData
+		}
+	}
+	return
+}
diff --git a/cmd/coordinator/internal/dashboard/handler.go b/cmd/coordinator/internal/dashboard/handler.go
new file mode 100644
index 0000000..7148823
--- /dev/null
+++ b/cmd/coordinator/internal/dashboard/handler.go
@@ -0,0 +1,313 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// +build go1.13
+// +build linux darwin
+
+// Package dashboard contains the implementation of the build dashboard for the Coordinator.
+package dashboard
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"html/template"
+	"log"
+	"net/http"
+	"sort"
+	"strings"
+	"time"
+
+	"cloud.google.com/go/datastore"
+	"golang.org/x/build/cmd/coordinator/internal"
+	"golang.org/x/build/dashboard"
+	"golang.org/x/build/maintner/maintnerd/apipb"
+	grpc4 "grpc.go4.org"
+)
+
+var firstClassPorts = map[string]bool{
+	"darwin-amd64":  true,
+	"linux-386":     true,
+	"linux-amd64":   true,
+	"linux-arm":     true,
+	"linux-arm64":   true,
+	"windows-386":   true,
+	"windows-amd64": true,
+}
+
+type data struct {
+	Branch    string
+	Builders  []*builder
+	Commits   []*commit
+	Dashboard struct {
+		Name string
+	}
+	Package    dashPackage
+	Pagination *struct{}
+	TagState   []struct{}
+}
+
+// MaintnerClient is a subset of apipb.MaintnerServiceClient.
+type MaintnerClient interface {
+	// GetDashboard is extracted from apipb.MaintnerServiceClient.
+	GetDashboard(ctx context.Context, in *apipb.DashboardRequest, opts ...grpc4.CallOption) (*apipb.DashboardResponse, error)
+}
+
+type Handler struct {
+	// Datastore is a client used for fetching build status. If nil, it uses in-memory storage of build status.
+	Datastore *datastore.Client
+	// Maintner is a client for Maintner, used for fetching lists of commits.
+	Maintner MaintnerClient
+
+	// memoryResults is an in-memory storage of CI results. Used in development and testing for datastore data.
+	memoryResults map[string][]string
+}
+
+func (d *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
+	dd := &data{
+		Builders: d.getBuilders(dashboard.Builders),
+		Commits:  d.commits(r.Context()),
+		Package:  dashPackage{Name: "Go"},
+	}
+
+	var buf bytes.Buffer
+	if err := templ.Execute(&buf, dd); err != nil {
+		log.Printf("handleDashboard: error rendering template: %v", err)
+		http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+		return
+	}
+	buf.WriteTo(rw)
+}
+
+func (d *Handler) commits(ctx context.Context) []*commit {
+	var commits []*commit
+	resp, err := d.Maintner.GetDashboard(ctx, &apipb.DashboardRequest{})
+	if err != nil {
+		log.Printf("handleDashboard: error fetching from maintner: %v", err)
+		return commits
+	}
+	for _, c := range resp.GetCommits() {
+		commits = append(commits, &commit{
+			Desc: c.Title,
+			Hash: c.Commit,
+			Time: time.Unix(c.CommitTimeSec, 0).Format("02 Jan 15:04"),
+			User: formatGitAuthor(c.AuthorName, c.AuthorEmail),
+		})
+	}
+	d.getResults(ctx, commits)
+	return commits
+}
+
+// getResults populates result data on commits, fetched from Datastore or in-memory storage.
+func (d *Handler) getResults(ctx context.Context, commits []*commit) {
+	if d.Datastore == nil {
+		for _, c := range commits {
+			if result, ok := d.memoryResults[c.Hash]; ok {
+				c.ResultData = result
+			}
+		}
+		return
+	}
+	getDatastoreResults(ctx, d.Datastore, commits, "go")
+}
+
+func (d *Handler) getBuilders(conf map[string]*dashboard.BuildConfig) []*builder {
+	bm := make(map[string]builder)
+	for _, b := range conf {
+		if !b.BuildsRepoPostSubmit("go", "master", "master") {
+			continue
+		}
+		db := bm[b.GOOS()]
+		db.OS = b.GOOS()
+		db.Archs = append(db.Archs, &arch{
+			Arch: b.GOARCH(),
+			Name: b.Name,
+			Tag:  strings.TrimPrefix(b.Name, fmt.Sprintf("%s-%s-", b.GOOS(), b.GOARCH())),
+		})
+		bm[b.GOOS()] = db
+	}
+	var builders builderSlice
+	for _, db := range bm {
+		db := db
+		sort.Sort(&db.Archs)
+		builders = append(builders, &db)
+	}
+	sort.Sort(builders)
+	return builders
+}
+
+type arch struct {
+	Arch string
+	Name string
+	Tag  string
+}
+
+func (a arch) FirstClass() bool {
+	segs := strings.SplitN(a.Name, "-", 3)
+	if len(segs) < 2 {
+		return false
+	}
+	if fc, ok := firstClassPorts[strings.Join(segs[0:2], "-")]; ok {
+		return fc
+	}
+	return false
+}
+
+type archSlice []*arch
+
+func (d archSlice) Len() int {
+	return len(d)
+}
+
+// Less sorts first-class ports first, then it sorts by name.
+func (d archSlice) Less(i, j int) bool {
+	iFirst, jFirst := d[i].FirstClass(), d[j].FirstClass()
+	if iFirst && !jFirst {
+		return true
+	}
+	if !iFirst && jFirst {
+		return false
+	}
+	return d[i].Name < d[j].Name
+}
+
+func (d archSlice) Swap(i, j int) {
+	d[i], d[j] = d[j], d[i]
+}
+
+type builder struct {
+	Active      bool
+	Archs       archSlice
+	OS          string
+	Unsupported bool
+}
+
+func (b *builder) FirstClass() bool {
+	for _, a := range b.Archs {
+		if a.FirstClass() {
+			return true
+		}
+	}
+	return false
+}
+
+func (b *builder) FirstClassArchs() archSlice {
+	var as archSlice
+	for _, a := range b.Archs {
+		if a.FirstClass() {
+			as = append(as, a)
+		}
+	}
+	return as
+}
+
+type builderSlice []*builder
+
+func (d builderSlice) Len() int {
+	return len(d)
+}
+
+// Less sorts first-class ports first, then it sorts by name.
+func (d builderSlice) Less(i, j int) bool {
+	iFirst, jFirst := d[i].FirstClass(), d[j].FirstClass()
+	if iFirst && !jFirst {
+		return true
+	}
+	if !iFirst && jFirst {
+		return false
+	}
+	return d[i].OS < d[j].OS
+}
+
+func (d builderSlice) Swap(i, j int) {
+	d[i], d[j] = d[j], d[i]
+}
+
+type dashPackage struct {
+	Name string
+	Path string
+}
+
+type commit struct {
+	Desc       string
+	Hash       string
+	ResultData []string
+	Time       string
+	User       string
+}
+
+// shortUser returns a shortened version of a user string.
+func (c *commit) ShortUser() string {
+	user := c.User
+	if i, j := strings.Index(user, "<"), strings.Index(user, ">"); 0 <= i && i < j {
+		user = user[i+1 : j]
+	}
+	if i := strings.Index(user, "@"); i >= 0 {
+		return user[:i]
+	}
+	return user
+}
+
+func (c *commit) ResultForBuilder(builder string) result {
+	for _, rd := range c.ResultData {
+		segs := strings.Split(rd, "|")
+		if len(segs) < 4 {
+			continue
+		}
+		if segs[0] == builder {
+			return result{
+				OK:      segs[1] == "true",
+				LogHash: segs[2],
+			}
+		}
+	}
+	return result{}
+}
+
+type result struct {
+	BuildingURL string
+	OK          bool
+	LogHash     string
+}
+
+// formatGitAuthor formats the git author name and email (as split by
+// maintner) back into the unified string how they're stored in a git
+// commit, so the shortUser func (used by the HTML template) can parse
+// back out the email part's username later. Maybe we could plumb down
+// the parsed proto into the template later.
+func formatGitAuthor(name, email string) string {
+	name = strings.TrimSpace(name)
+	email = strings.TrimSpace(email)
+	if name != "" && email != "" {
+		return fmt.Sprintf("%s <%s>", name, email)
+	}
+	if name != "" {
+		return name
+	}
+	return "<" + email + ">"
+}
+
+var templ = template.Must(
+	template.New("dashboard.html").ParseFiles(
+		internal.FilePath("dashboard.html", "internal/dashboard", "cmd/coordinator/internal/dashboard"),
+	),
+)
+
+// A Commit describes an individual commit in a package.
+//
+// Each Commit entity is a descendant of its associated Package entity.
+// In other words, all Commits with the same PackagePath belong to the same
+// datastore entity group.
+type Commit struct {
+	PackagePath string // (empty for main repo commits)
+	Hash        string
+
+	// ResultData is the Data string of each build Result for this Commit.
+	// For non-Go commits, only the Results for the current Go tip, weekly,
+	// and release Tags are stored here. This is purely de-normalized data.
+	// The complete data set is stored in Result entities.
+	//
+	// Each string is formatted as builder|OK|LogHash|GoHash.
+	ResultData []string `datastore:",noindex"`
+}
diff --git a/cmd/coordinator/internal/dashboard/handler_test.go b/cmd/coordinator/internal/dashboard/handler_test.go
new file mode 100644
index 0000000..138e27a
--- /dev/null
+++ b/cmd/coordinator/internal/dashboard/handler_test.go
@@ -0,0 +1,218 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// +build go1.13
+// +build linux
+
+package dashboard
+
+import (
+	"context"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/build/dashboard"
+	"golang.org/x/build/maintner/maintnerd/apipb"
+	"golang.org/x/build/types"
+	grpc4 "grpc.go4.org"
+)
+
+type fakeMaintner struct {
+	resp *apipb.DashboardResponse
+}
+
+func (f *fakeMaintner) GetDashboard(ctx context.Context, in *apipb.DashboardRequest, opts ...grpc4.CallOption) (*apipb.DashboardResponse, error) {
+	return f.resp, nil
+}
+
+func TestHandlerServeHTTP(t *testing.T) {
+	fm := &fakeMaintner{
+		resp: &apipb.DashboardResponse{Commits: []*apipb.DashCommit{
+			{
+				Title:         "x/build/cmd/coordinator: implement dashboard",
+				Commit:        "752029e171d535b0dd4ff7bbad5ad0275a3969a8",
+				CommitTimeSec: 1257894000,
+				AuthorName:    "Gopherbot",
+				AuthorEmail:   "gopherbot@example.com",
+			},
+		}},
+	}
+	dh := &Handler{
+		Maintner: fm,
+		memoryResults: map[string][]string{
+			"752029e171d535b0dd4ff7bbad5ad0275a3969a8": {"linux-amd64-longtest|true|SomeLog|752029e171d535b0dd4ff7bbad5ad0275a3969a8"},
+		},
+	}
+	req := httptest.NewRequest("GET", "/dashboard", nil)
+	w := httptest.NewRecorder()
+
+	dh.ServeHTTP(w, req)
+	resp := w.Result()
+	defer resp.Body.Close()
+	ioutil.ReadAll(resp.Body)
+
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("resp.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
+	}
+}
+
+func TestHandlerCommits(t *testing.T) {
+	fm := &fakeMaintner{
+		resp: &apipb.DashboardResponse{Commits: []*apipb.DashCommit{
+			{
+				Title:         "x/build/cmd/coordinator: implement dashboard",
+				Commit:        "752029e171d535b0dd4ff7bbad5ad0275a3969a8",
+				CommitTimeSec: 1257894000,
+				AuthorName:    "Gopherbot",
+				AuthorEmail:   "gopherbot@example.com",
+			},
+		}},
+	}
+	dh := &Handler{
+		Maintner: fm,
+		memoryResults: map[string][]string{
+			"752029e171d535b0dd4ff7bbad5ad0275a3969a8": {"test-builder|true|SomeLog|752029e171d535b0dd4ff7bbad5ad0275a3969a8"},
+		},
+	}
+	want := []*commit{
+		{
+			Desc:       "x/build/cmd/coordinator: implement dashboard",
+			Hash:       "752029e171d535b0dd4ff7bbad5ad0275a3969a8",
+			Time:       time.Unix(1257894000, 0).Format("02 Jan 15:04"),
+			User:       "Gopherbot <gopherbot@example.com>",
+			ResultData: []string{"test-builder|true|SomeLog|752029e171d535b0dd4ff7bbad5ad0275a3969a8"},
+		},
+	}
+
+	got := dh.commits(context.Background())
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("dh.Commits() mismatch (-want +got):\n%s", diff)
+	}
+}
+
+func TestHandlerGetBuilders(t *testing.T) {
+	dh := &Handler{}
+	builders := map[string]*dashboard.BuildConfig{
+		"linux-amd64-testfile": {
+			Name:             "linux-amd64-testfile",
+			HostType:         "this-is-a-test-file",
+			Notes:            "",
+			MinimumGoVersion: types.MajorMinor{},
+		},
+		"linux-386-testfile": {
+			Name:             "linux-386-testfile",
+			HostType:         "this-is-a-test-file",
+			Notes:            "",
+			MinimumGoVersion: types.MajorMinor{},
+		},
+		"darwin-amd64-testfile": {
+			Name:             "darwin-amd64-testfile",
+			HostType:         "this-is-a-test-file",
+			Notes:            "",
+			MinimumGoVersion: types.MajorMinor{},
+		},
+		"android-386-testfile": {
+			Name:             "android-386-testfile",
+			HostType:         "this-is-a-test-file",
+			Notes:            "",
+			MinimumGoVersion: types.MajorMinor{},
+		},
+	}
+	want := []*builder{
+		{
+			OS: "darwin",
+			Archs: []*arch{
+				{
+					Arch: "amd64",
+					Name: "darwin-amd64-testfile",
+					Tag:  "testfile",
+				},
+			},
+		},
+		{
+			OS: "linux",
+			Archs: []*arch{
+				{
+					Arch: "386",
+					Name: "linux-386-testfile",
+					Tag:  "testfile",
+				},
+				{
+					Arch: "amd64",
+					Name: "linux-amd64-testfile",
+					Tag:  "testfile",
+				},
+			},
+		},
+		{
+			OS: "android",
+			Archs: []*arch{
+				{
+					Arch: "386",
+					Name: "android-386-testfile",
+					Tag:  "testfile",
+				},
+			},
+		},
+	}
+
+	got := dh.getBuilders(builders)
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("dh.getBuilders() mismatch (-want +got):\n%s", diff)
+	}
+}
+
+func TestArchFirstClass(t *testing.T) {
+	cases := []struct {
+		name string
+		want bool
+	}{
+		{
+			name: "linux-amd64-longtest",
+			want: true,
+		},
+		{
+			name: "linux-buzz-longtest",
+			want: false,
+		},
+		{
+			name: "linux-amd64",
+			want: true,
+		},
+		{
+			name: "linux",
+			want: false,
+		},
+	}
+	for _, c := range cases {
+		a := &arch{Name: c.name}
+		if a.FirstClass() != c.want {
+			t.Errorf("%+v.FirstClass() = %v, wanted %v", a, a.FirstClass(), c.want)
+		}
+	}
+}
+
+func TestCommitResultForBuilder(t *testing.T) {
+	c := &commit{
+		Desc:       "x/build/cmd/coordinator: implement dashboard",
+		Hash:       "752029e171d535b0dd4ff7bbad5ad0275a3969a8",
+		Time:       "10 Nov 18:00",
+		User:       "Gopherbot <gopherbot@example.com>",
+		ResultData: []string{"test-builder|true|SomeLog|752029e171d535b0dd4ff7bbad5ad0275a3969a8"},
+	}
+	want := result{
+		OK:      true,
+		LogHash: "SomeLog",
+	}
+	got := c.ResultForBuilder("test-builder")
+
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("c.ResultForBuilder(%q) mismatch (-want +got):\n%s", "test-builder", diff)
+	}
+}
diff --git a/cmd/coordinator/internal/internal.go b/cmd/coordinator/internal/internal.go
new file mode 100644
index 0000000..b1c0e1e
--- /dev/null
+++ b/cmd/coordinator/internal/internal.go
@@ -0,0 +1,20 @@
+package internal
+
+import (
+	"os"
+	"path/filepath"
+)
+
+// FilePath returns the path to the specified file. If the file is not found
+// in the current directory, it will return a relative path for the prefix
+// that the file exists at.
+func FilePath(base string, prefixes ...string) string {
+	// First, attempt to find the file with no prefix.
+	prefixes = append([]string{""}, prefixes...)
+	for _, p := range prefixes {
+		if _, err := os.Stat(filepath.Join(p, base)); err == nil {
+			return filepath.Join(p, base)
+		}
+	}
+	return base
+}
diff --git a/cmd/coordinator/status.go b/cmd/coordinator/status.go
index ab05732..3e57f74 100644
--- a/cmd/coordinator/status.go
+++ b/cmd/coordinator/status.go
@@ -29,6 +29,7 @@
 	"sync/atomic"
 	"time"
 
+	"golang.org/x/build/cmd/coordinator/internal"
 	"golang.org/x/build/dashboard"
 	"golang.org/x/build/internal/foreach"
 )
@@ -794,83 +795,19 @@
 </html>
 `))
 
+var styleCSS []byte
+
+// loadStatic loads static resources into memroy for serving.
+func loadStatic() error {
+	path := internal.FilePath("style.css", "cmd/coordinator")
+	css, err := ioutil.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("ioutil.ReadFile(%q): %w", path, err)
+	}
+	styleCSS = css
+	return nil
+}
+
 func handleStyleCSS(w http.ResponseWriter, r *http.Request) {
-	src := strings.NewReader(styleCSS)
-	http.ServeContent(w, r, "style.css", processStartTime, src)
+	http.ServeContent(w, r, "style.css", processStartTime, bytes.NewReader(styleCSS))
 }
-
-const styleCSS = `
-body {
-	font-family: sans-serif;
-	color: #222;
-	padding: 10px;
-	margin: 0;
-}
-
-h1, h2, h1 > a, h2 > a, h1 > a:visited, h2 > a:visited {
-	color: #375EAB;
-}
-h1 { font-size: 24px; }
-h2 { font-size: 20px; }
-
-h1 > a, h2 > a {
-	display: none;
-	text-decoration: none;
-}
-
-h1:hover > a, h2:hover > a {
-	display: inline;
-}
-
-h1 > a:hover, h2 > a:hover {
-	text-decoration: underline;
-}
-
-pre {
-	font-family: monospace;
-	font-size: 9pt;
-}
-
-header {
-	margin: -10px -10px 0 -10px;
-	padding: 10px 10px;
-	background: #E0EBF5;
-}
-header a { color: #222; }
-header h1 {
-	display: inline;
-	margin: 0;
-	padding-top: 5px;
-}
-header nav {
-	display: inline-block;
-	margin-left: 20px;
-}
-header nav a {
-	display: inline-block;
-	padding: 10px;
-	margin: 0;
-	margin-right: 5px;
-	color: white;
-	background: #375EAB;
-	text-decoration: none;
-	font-size: 16px;
-	border: 1px solid #375EAB;
-	border-radius: 5px;
-}
-
-table {
-	border-collapse: collapse;
-	font-size: 9pt;
-}
-
-table td, table th, table td, table th {
-	text-align: left;
-	vertical-align: top;
-	padding: 2px 6px;
-}
-
-table thead tr {
-	background: #fff !important;
-}
-`
diff --git a/cmd/coordinator/style.css b/cmd/coordinator/style.css
new file mode 100644
index 0000000..da98275
--- /dev/null
+++ b/cmd/coordinator/style.css
@@ -0,0 +1,161 @@
+* {
+  box-sizing: border-box;
+}
+body {
+  color: #222;
+  font-family: sans-serif;
+  margin: 0;
+  padding: 10px;
+}
+
+h1,
+h2,
+h1 > a,
+h2 > a,
+h1 > a:visited,
+h2 > a:visited {
+  color: #375eab;
+}
+h1 {
+  font-size: 24px;
+}
+h2 {
+  font-size: 20px;
+}
+
+h1 > a,
+h2 > a {
+  display: none;
+  text-decoration: none;
+}
+
+h1:hover > a,
+h2:hover > a {
+  display: inline;
+}
+
+h1 > a:hover,
+h2 > a:hover {
+  text-decoration: underline;
+}
+
+pre {
+  font-family: monospace;
+  font-size: 9pt;
+}
+
+header {
+  background: #e0ebf5;
+  margin: -10px -10px 0 -10px;
+  padding: 10px 10px;
+}
+header a {
+  color: #222;
+}
+header h1 {
+  display: inline;
+  margin: 0;
+  padding-top: 5px;
+}
+header nav {
+  display: inline-block;
+  margin-left: 20px;
+}
+header nav a {
+  background: #375eab;
+  border: 1px solid #375eab;
+  border-radius: 5px;
+  color: white;
+  display: inline-block;
+  font-size: 16px;
+  margin: 0;
+  margin-right: 5px;
+  padding: 10px;
+  text-decoration: none;
+}
+
+table {
+  border-collapse: collapse;
+  font-size: 9pt;
+}
+
+table td,
+table th,
+table td,
+table th {
+  padding: 2px 6px;
+  text-align: left;
+  vertical-align: top;
+}
+
+table thead tr {
+  background: #fff !important;
+}
+
+.Dashboard {
+  margin: 0;
+  padding: 0;
+}
+.Dashboard-topbar {
+  margin: 0;
+  padding: 1rem 0.625rem;
+}
+table.Build tbody tr:nth-child(even) {
+  background-color: #f4f4f4;
+}
+.ShortHash {
+  display: inline-block;
+  font-family: monospace;
+  overflow: hidden;
+  text-overflow: clip;
+  width: 3.2rem;
+}
+.Dashboard-packageName {
+  background: #e0ebf5;
+  padding: 0.125rem 0.3125rem;
+}
+.Dashboard-controls {
+  padding: 0.5rem;
+}
+.Build-scrollTable {
+  overflow-x: auto;
+}
+.Build-descriptionColumn,
+.Build-osColumn {
+  border-right: 0.0625rem solid #ccc;
+}
+.Build-resultArch {
+  max-width: 2rem;
+  overflow: hidden;
+  text-overflow: clip;
+  whitespace: nowrap;
+}
+.Build-user {
+  font-size: 0.8125rem;
+  max-width: 3.4rem;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.Build-commitTime {
+  color: #4e4e4e;
+  font-size: 0.8125rem;
+  white-space: nowrap;
+}
+.Build-desc {
+  max-width: 8.875rem;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.Build-builderOSRow th {
+  font-size: 0.8125rem;
+  text-align: center;
+}
+.Build-builderArchRow th,
+.Build-builderTagRow th {
+  font-size: 0.5625rem;
+  font-weight: normal;
+  text-align: center;
+  white-space: nowrap;
+}