cmd/coordinator/internal/legacydash: convert app/appengine to library

Add a copy of x/build/app/appengine while making minimal modifications
to make it a package that coordinator can import instead of a command.

It'll be integrated into cmd/coordinator in the next CL.
The CL following that will delete the app/appengine code.

For golang/go#34744.

Change-Id: I6ee6e9547144c56fc04147a17f46c83d1bb37933
Reviewed-on: https://go-review.googlesource.com/c/build/+/336790
Run-TryBot: Dmitri Shuralyov <dmitshur@golang.org>
Trust: Dmitri Shuralyov <dmitshur@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
diff --git a/cmd/coordinator/dash.go b/cmd/coordinator/dash.go
index e21c9b5..d3c5d6b 100644
--- a/cmd/coordinator/dash.go
+++ b/cmd/coordinator/dash.go
@@ -42,7 +42,7 @@
 // If resp is non-nil the server's response is decoded into the value pointed
 // to by resp (resp must be a pointer).
 func dash(meth, cmd string, args url.Values, req, resp interface{}) error {
-	const builderVersion = 1 // keep in sync with dashboard/app/build/handler.go
+	const builderVersion = 1 // keep in sync with cmd/coordinator/internal/legacydash/handler.go
 	argsCopy := url.Values{"version": {fmt.Sprint(builderVersion)}}
 	for k, v := range args {
 		if k == "version" {
diff --git a/cmd/coordinator/internal/legacydash/README.md b/cmd/coordinator/internal/legacydash/README.md
new file mode 100644
index 0000000..561b293
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/README.md
@@ -0,0 +1,7 @@
+<!-- Auto-generated by x/build/update-readmes.go -->
+
+[![Go Reference](https://pkg.go.dev/badge/golang.org/x/build/cmd/coordinator/internal/legacydash.svg)](https://pkg.go.dev/golang.org/x/build/cmd/coordinator/internal/legacydash)
+
+# golang.org/x/build/cmd/coordinator/internal/legacydash
+
+Package legacydash holds the serving code for the build dashboard (build.golang.org) and its remaining HTTP API endpoints.
diff --git a/cmd/coordinator/internal/legacydash/build.go b/cmd/coordinator/internal/legacydash/build.go
new file mode 100644
index 0000000..f6896fe
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/build.go
@@ -0,0 +1,460 @@
+// Copyright 2011 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.
+
+//go:build go1.16
+// +build go1.16
+
+package legacydash
+
+import (
+	"bytes"
+	"compress/gzip"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"math/rand"
+	pathpkg "path"
+	"strings"
+
+	"cloud.google.com/go/datastore"
+	"golang.org/x/build/dashboard"
+	"golang.org/x/build/internal/loghash"
+)
+
+const (
+	maxDatastoreStringLen = 500
+)
+
+func dsKey(kind, name string, parent *datastore.Key) *datastore.Key {
+	dk := datastore.NameKey(kind, name, parent)
+	dk.Namespace = "Git"
+	return dk
+}
+
+// A Package describes a package that is listed on the dashboard.
+type Package struct {
+	Name string // "Go", "arch", "net", ...
+	Path string // empty for the main Go tree, else "golang.org/x/foo"
+}
+
+func (p *Package) String() string {
+	return fmt.Sprintf("%s: %q", p.Path, p.Name)
+}
+
+func (p *Package) Key() *datastore.Key {
+	key := p.Path
+	if key == "" {
+		key = "go"
+	}
+	return dsKey("Package", key, nil)
+}
+
+// filterDatastoreError returns err, unless it's just about datastore
+// not being able to load an entity with old legacy struct fields into
+// the Commit type that has since removed those fields.
+func filterDatastoreError(err error) error {
+	return filterAppEngineError(err, func(err error) bool {
+		if em, ok := err.(*datastore.ErrFieldMismatch); ok {
+			switch em.FieldName {
+			case "NeedsBenchmarking", "TryPatch", "FailNotificationSent":
+				// Removed in CLs 208397 and 208324.
+				return true
+			case "PackagePath", "ParentHash", "Num", "User", "Desc", "Time", "Branch", "NextNum", "Kind":
+				// Removed in move to maintner in CL 208697.
+				return true
+			}
+		}
+		return false
+	})
+}
+
+// filterNoSuchEntity returns err, unless it's just about datastore
+// not being able to load an entity because it doesn't exist.
+func filterNoSuchEntity(err error) error {
+	return filterAppEngineError(err, func(err error) bool {
+		return err == datastore.ErrNoSuchEntity
+	})
+}
+
+// filterAppEngineError returns err, unless ignore(err) is true,
+// in which case it returns nil. If err is an datastore.MultiError,
+// it returns either nil (if all errors are ignored) or a deep copy
+// with the non-ignored errors.
+func filterAppEngineError(err error, ignore func(error) bool) error {
+	if err == nil || ignore(err) {
+		return nil
+	}
+	if me, ok := err.(datastore.MultiError); ok {
+		me2 := make(datastore.MultiError, 0, len(me))
+		for _, err := range me {
+			if e2 := filterAppEngineError(err, ignore); e2 != nil {
+				me2 = append(me2, e2)
+			}
+		}
+		if len(me2) == 0 {
+			return nil
+		}
+		return me2
+	}
+	return err
+}
+
+// getOrMakePackageInTx fetches a Package by path from the datastore,
+// creating it if necessary.
+func getOrMakePackageInTx(ctx context.Context, tx *datastore.Transaction, path string) (*Package, error) {
+	p := &Package{Path: path}
+	if path != "" {
+		p.Name = pathpkg.Base(path)
+	} else {
+		p.Name = "Go"
+	}
+	err := tx.Get(p.Key(), p)
+	err = filterDatastoreError(err)
+	if err == datastore.ErrNoSuchEntity {
+		if _, err := tx.Put(p.Key(), p); err != nil {
+			return nil, err
+		}
+		return p, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return p, nil
+}
+
+type builderAndGoHash struct {
+	builder, goHash string
+}
+
+// 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.
+	ResultData []string `datastore:",noindex"`
+}
+
+func (com *Commit) Key() *datastore.Key {
+	if com.Hash == "" {
+		panic("tried Key on Commit with empty Hash")
+	}
+	p := Package{Path: com.PackagePath}
+	key := com.PackagePath + "|" + com.Hash
+	return dsKey("Commit", key, p.Key())
+}
+
+// Valid reports whether the commit is valid.
+func (c *Commit) Valid() bool {
+	// Valid really just means the hash is populated.
+	return validHash(c.Hash)
+}
+
+// each result line is approx 105 bytes. This constant is a tradeoff between
+// build history and the AppEngine datastore limit of 1mb.
+const maxResults = 1000
+
+// AddResult adds the denormalized Result data to the Commit's
+// ResultData field.
+func (com *Commit) AddResult(tx *datastore.Transaction, r *Result) error {
+	err := tx.Get(com.Key(), com)
+	if err == datastore.ErrNoSuchEntity {
+		// If it doesn't exist, we create it below.
+	} else {
+		err = filterDatastoreError(err)
+		if err != nil {
+			return fmt.Errorf("Commit.AddResult, getting Commit: %v", err)
+		}
+	}
+
+	var resultExists bool
+	for i, s := range com.ResultData {
+		// if there already exists result data for this builder at com, overwrite it.
+		if strings.HasPrefix(s, r.Builder+"|") && strings.HasSuffix(s, "|"+r.GoHash) {
+			resultExists = true
+			com.ResultData[i] = r.Data()
+		}
+	}
+	if !resultExists {
+		// otherwise, add the new result data for this builder.
+		com.ResultData = trim(append(com.ResultData, r.Data()), maxResults)
+	}
+	if !com.Valid() {
+		return errors.New("putting Commit: commit is not valid")
+	}
+	if _, err := tx.Put(com.Key(), com); err != nil {
+		return fmt.Errorf("putting Commit: %v", err)
+	}
+	return nil
+}
+
+// removeResult removes the denormalized Result data from the ResultData field
+// for the given builder and go hash.
+// It must be called from within the datastore transaction that gets and puts
+// the Commit. Note this is slightly different to AddResult, above.
+func (com *Commit) RemoveResult(r *Result) {
+	var rd []string
+	for _, s := range com.ResultData {
+		if strings.HasPrefix(s, r.Builder+"|") && strings.HasSuffix(s, "|"+r.GoHash) {
+			continue
+		}
+		rd = append(rd, s)
+	}
+	com.ResultData = rd
+}
+
+func trim(s []string, n int) []string {
+	l := min(len(s), n)
+	return s[len(s)-l:]
+}
+
+func min(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}
+
+// Result returns the build Result for this Commit for the given builder/goHash.
+//
+// For the main Go repo, goHash is the empty string.
+func (c *Commit) Result(builder, goHash string) *Result {
+	return result(c.ResultData, c.Hash, c.PackagePath, builder, goHash)
+}
+
+// Result returns the build Result for this commit for the given builder/goHash.
+//
+// For the main Go repo, goHash is the empty string.
+func (c *CommitInfo) Result(builder, goHash string) *Result {
+	if r := result(c.ResultData, c.Hash, c.PackagePath, builder, goHash); r != nil {
+		return r
+	}
+	if u, ok := c.BuildingURLs[builderAndGoHash{builder, goHash}]; ok {
+		return &Result{
+			Builder:     builder,
+			BuildingURL: u,
+			Hash:        c.Hash,
+			GoHash:      goHash,
+		}
+	}
+	if fakeResults {
+		// Create a fake random result.
+		switch rand.Intn(3) {
+		default:
+			return nil
+		case 1:
+			return &Result{
+				Builder: builder,
+				Hash:    c.Hash,
+				GoHash:  goHash,
+				OK:      true,
+			}
+		case 2:
+			return &Result{
+				Builder: builder,
+				Hash:    c.Hash,
+				GoHash:  goHash,
+				LogHash: "fakefailureurl",
+			}
+		}
+	}
+	return nil
+}
+
+func result(resultData []string, hash, packagePath, builder, goHash string) *Result {
+	for _, r := range resultData {
+		if !strings.HasPrefix(r, builder) {
+			// Avoid strings.SplitN alloc in the common case.
+			continue
+		}
+		p := strings.SplitN(r, "|", 4)
+		if len(p) != 4 || p[0] != builder || p[3] != goHash {
+			continue
+		}
+		return partsToResult(hash, packagePath, p)
+	}
+	return nil
+}
+
+// isUntested reports whether a cell in the build.golang.org grid is
+// an untested configuration.
+//
+// repo is "go", "net", etc.
+// branch is the branch of repo "master" or "release-branch.go1.12"
+// goBranch applies only if repo != "go" and is of form "master" or "release-branch.go1.N"
+//
+// As a special case, "tip" is an alias for "master", since this app
+// still uses a bunch of hg terms from when we used hg.
+func isUntested(builder, repo, branch, goBranch string) bool {
+	if branch == "tip" {
+		branch = "master"
+	}
+	if goBranch == "tip" {
+		goBranch = "master"
+	}
+	bc, ok := dashboard.Builders[builder]
+	if !ok {
+		// Unknown builder, so not tested.
+		return true
+	}
+	return !bc.BuildsRepoPostSubmit(repo, branch, goBranch)
+}
+
+// knownIssue returns a known issue for the named builder,
+// or zero if there isn't a known issue.
+func knownIssue(builder string) int {
+	bc, ok := dashboard.Builders[builder]
+	if !ok {
+		// Unknown builder.
+		return 0
+	}
+	return bc.KnownIssue
+}
+
+// Results returns the build Results for this Commit.
+func (c *CommitInfo) Results() (results []*Result) {
+	for _, r := range c.ResultData {
+		p := strings.SplitN(r, "|", 4)
+		if len(p) != 4 {
+			continue
+		}
+		results = append(results, partsToResult(c.Hash, c.PackagePath, p))
+	}
+	return
+}
+
+// ResultGoHashes, for non-go repos, returns the list of Go hashes that
+// this repo has been (or should be) built at.
+//
+// For the main Go repo it always returns a slice with 1 element: the
+// empty string.
+func (c *CommitInfo) ResultGoHashes() []string {
+	// For the main repo, just return the empty string
+	// (there's no corresponding main repo hash for a main repo Commit).
+	// This function is only really useful for sub-repos.
+	if c.PackagePath == "" {
+		return []string{""}
+	}
+	var hashes []string
+	for _, r := range c.ResultData {
+		p := strings.SplitN(r, "|", 4)
+		if len(p) != 4 {
+			continue
+		}
+		// Append only new results (use linear scan to preserve order).
+		if !contains(hashes, p[3]) {
+			hashes = append(hashes, p[3])
+		}
+	}
+	// Return results in reverse order (newest first).
+	reverse(hashes)
+	return hashes
+}
+
+func contains(t []string, s string) bool {
+	for _, s2 := range t {
+		if s2 == s {
+			return true
+		}
+	}
+	return false
+}
+
+func reverse(s []string) {
+	for i := 0; i < len(s)/2; i++ {
+		j := len(s) - i - 1
+		s[i], s[j] = s[j], s[i]
+	}
+}
+
+// partsToResult creates a Result from ResultData substrings.
+func partsToResult(hash, packagePath string, p []string) *Result {
+	return &Result{
+		Builder:     p[0],
+		Hash:        hash,
+		PackagePath: packagePath,
+		GoHash:      p[3],
+		OK:          p[1] == "true",
+		LogHash:     p[2],
+	}
+}
+
+// A Result describes a build result for a Commit on an OS/architecture.
+//
+// Each Result entity is a descendant of its associated Package entity.
+type Result struct {
+	Builder     string // "os-arch[-note]"
+	PackagePath string // (empty for Go commits, else "golang.org/x/foo")
+	Hash        string
+
+	// The Go Commit this was built against (when PackagePath != ""; empty for Go commits).
+	GoHash string
+
+	BuildingURL string `datastore:"-"` // non-empty if currently building
+	OK          bool
+	Log         string `datastore:"-"`        // for JSON unmarshaling only
+	LogHash     string `datastore:",noindex"` // Key to the Log record.
+
+	RunTime int64 // time to build+test in nanoseconds
+}
+
+func (r *Result) Key() *datastore.Key {
+	p := Package{Path: r.PackagePath}
+	key := r.Builder + "|" + r.PackagePath + "|" + r.Hash + "|" + r.GoHash
+	return dsKey("Result", key, p.Key())
+}
+
+func (r *Result) Valid() error {
+	if !validHash(r.Hash) {
+		return errors.New("invalid Hash")
+	}
+	if r.PackagePath != "" && !validHash(r.GoHash) {
+		return errors.New("invalid GoHash")
+	}
+	return nil
+}
+
+// Data returns the Result in string format
+// to be stored in Commit's ResultData field.
+func (r *Result) Data() string {
+	return fmt.Sprintf("%v|%v|%v|%v", r.Builder, r.OK, r.LogHash, r.GoHash)
+}
+
+// A Log is a gzip-compressed log file stored under the SHA1 hash of the
+// uncompressed log text.
+type Log struct {
+	CompressedLog []byte `datastore:",noindex"`
+}
+
+func (l *Log) Text() ([]byte, error) {
+	d, err := gzip.NewReader(bytes.NewBuffer(l.CompressedLog))
+	if err != nil {
+		return nil, fmt.Errorf("reading log data: %v", err)
+	}
+	b, err := ioutil.ReadAll(d)
+	if err != nil {
+		return nil, fmt.Errorf("reading log data: %v", err)
+	}
+	return b, nil
+}
+
+func PutLog(c context.Context, text string) (hash string, err error) {
+	b := new(bytes.Buffer)
+	z, _ := gzip.NewWriterLevel(b, gzip.BestCompression)
+	io.WriteString(z, text)
+	z.Close()
+	hash = loghash.New(text)
+	key := dsKey("Log", hash, nil)
+	_, err = datastoreClient.Put(c, key, &Log{b.Bytes()})
+	return
+}
diff --git a/cmd/coordinator/internal/legacydash/dash.go b/cmd/coordinator/internal/legacydash/dash.go
new file mode 100644
index 0000000..51180dd
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/dash.go
@@ -0,0 +1,131 @@
+// Copyright 2013 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.
+
+//go:build go1.16
+// +build go1.16
+
+// Package legacydash holds the serving code for the build dashboard
+// (build.golang.org) and its remaining HTTP API endpoints.
+//
+// It's a code transplant of the previous app/appengine application,
+// converted into a package that coordinator can import and use.
+// A newer version of the build dashboard is in development in
+// the golang.org/x/build/cmd/coordinator/internal/dashboard package.
+package legacydash
+
+import (
+	"embed"
+	"net/http"
+	"sort"
+
+	"cloud.google.com/go/datastore"
+	"github.com/NYTimes/gziphandler"
+	"golang.org/x/build/maintner/maintnerd/apipb"
+	"golang.org/x/build/repos"
+)
+
+var (
+	// Datastore client to a GCP project where build results are stored.
+	// Typically this is the golang-org GCP project.
+	datastoreClient *datastore.Client
+
+	// Maintner client for the maintner service.
+	// Typically the one at maintner.golang.org.
+	maintnerClient apipb.MaintnerServiceClient
+
+	// The builder master key.
+	masterKey string
+
+	// TODO(golang.org/issue/38337): Keep moving away from package scope
+	// variables during future refactors.
+)
+
+// fakeResults controls whether to make up fake random results. If true, datastore is not used.
+const fakeResults = false
+
+// Handler sets a datastore client, maintner client, and builder master key
+// at the package scope, and returns an HTTP mux for the legacy dashboard.
+func Handler(dc *datastore.Client, mc apipb.MaintnerServiceClient, key string) http.Handler {
+	datastoreClient = dc
+	maintnerClient = mc
+	masterKey = key
+
+	mux := http.NewServeMux()
+
+	// authenticated handlers
+	mux.Handle("/clear-results", hstsGzip(AuthHandler(clearResultsHandler))) // called by coordinator for x/build/cmd/retrybuilds
+	mux.Handle("/result", hstsGzip(AuthHandler(resultHandler)))              // called by coordinator after build
+
+	// public handlers
+	mux.Handle("/", hstsGzip(http.HandlerFunc(uiHandler)))
+	mux.Handle("/log/", hstsGzip(http.HandlerFunc(logHandler)))
+
+	// static handler
+	fs := http.FileServer(http.FS(static))
+	mux.Handle("/static/", hstsGzip(fs))
+
+	return mux
+}
+
+//go:embed static
+var static embed.FS
+
+// hstsGzip is short for hstsHandler(GzipHandler(h)).
+func hstsGzip(h http.Handler) http.Handler {
+	return hstsHandler(gziphandler.GzipHandler(h))
+}
+
+// hstsHandler returns a Handler that sets the HSTS header but
+// otherwise just wraps h.
+func hstsHandler(h http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Strict-Transport-Security", "max-age=31536000; preload")
+		h.ServeHTTP(w, r)
+	})
+}
+
+// Dashboard describes a unique build dashboard.
+//
+// (There used to be more than one dashboard, so this is now somewhat
+// less important than it once was.)
+type Dashboard struct {
+	Name     string     // This dashboard's name (always "Go" nowadays)
+	Packages []*Package // The project's packages to build
+}
+
+// packageWithPath returns the Package in d with the provided importPath,
+// or nil if none is found.
+func (d *Dashboard) packageWithPath(importPath string) *Package {
+	for _, p := range d.Packages {
+		if p.Path == importPath {
+			return p
+		}
+	}
+	return nil
+}
+
+// goDash is the dashboard for the main go repository.
+var goDash = &Dashboard{
+	Name: "Go",
+	Packages: []*Package{
+		{Name: "Go"},
+	},
+}
+
+func init() {
+	var add []*Package
+	for _, r := range repos.ByGerritProject {
+		if !r.ShowOnDashboard() {
+			continue
+		}
+		add = append(add, &Package{
+			Name: r.GoGerritProject,
+			Path: r.ImportPath,
+		})
+	}
+	sort.Slice(add, func(i, j int) bool {
+		return add[i].Name < add[j].Name
+	})
+	goDash.Packages = append(goDash.Packages, add...)
+}
diff --git a/cmd/coordinator/internal/legacydash/handler.go b/cmd/coordinator/internal/legacydash/handler.go
new file mode 100644
index 0000000..7472a86
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/handler.go
@@ -0,0 +1,275 @@
+// Copyright 2011 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.
+
+//go:build go1.16
+// +build go1.16
+
+package legacydash
+
+import (
+	"context"
+	"crypto/hmac"
+	"crypto/md5"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"html"
+	"log"
+	"net/http"
+	"strconv"
+	"strings"
+	"unicode/utf8"
+
+	"cloud.google.com/go/datastore"
+)
+
+const (
+	commitsPerPage = 30
+	builderVersion = 1 // must match x/build/cmd/coordinator/dash.go's value
+)
+
+// resultHandler records a build result.
+// It reads a JSON-encoded Result value from the request body,
+// creates a new Result entity, and creates or updates the relevant Commit entity.
+// If the Log field is not empty, resultHandler creates a new Log entity
+// and updates the LogHash field before putting the Commit entity.
+func resultHandler(r *http.Request) (interface{}, error) {
+	if r.Method != "POST" {
+		return nil, errBadMethod(r.Method)
+	}
+
+	v, _ := strconv.Atoi(r.FormValue("version"))
+	if v != builderVersion {
+		return nil, fmt.Errorf("rejecting POST from builder; need version %v instead of %v",
+			builderVersion, v)
+	}
+
+	ctx := r.Context()
+	res := new(Result)
+	defer r.Body.Close()
+	if err := json.NewDecoder(r.Body).Decode(res); err != nil {
+		return nil, fmt.Errorf("decoding Body: %v", err)
+	}
+	if err := res.Valid(); err != nil {
+		return nil, fmt.Errorf("validating Result: %v", err)
+	}
+	// store the Log text if supplied
+	if len(res.Log) > 0 {
+		hash, err := PutLog(ctx, res.Log)
+		if err != nil {
+			return nil, fmt.Errorf("putting Log: %v", err)
+		}
+		res.LogHash = hash
+	}
+	tx := func(tx *datastore.Transaction) error {
+		if _, err := getOrMakePackageInTx(ctx, tx, res.PackagePath); err != nil {
+			return fmt.Errorf("GetPackage: %v", err)
+		}
+		// put Result
+		if _, err := tx.Put(res.Key(), res); err != nil {
+			return fmt.Errorf("putting Result: %v", err)
+		}
+		// add Result to Commit
+		com := &Commit{PackagePath: res.PackagePath, Hash: res.Hash}
+		if err := com.AddResult(tx, res); err != nil {
+			return fmt.Errorf("AddResult: %v", err)
+		}
+		return nil
+	}
+	_, err := datastoreClient.RunInTransaction(ctx, tx)
+	return nil, err
+}
+
+// logHandler displays log text for a given hash.
+// It handles paths like "/log/hash".
+func logHandler(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-type", "text/plain; charset=utf-8")
+	c := r.Context()
+	hash := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
+	key := dsKey("Log", hash, nil)
+	l := new(Log)
+	if err := datastoreClient.Get(c, key, l); err != nil {
+		if err == datastore.ErrNoSuchEntity {
+			// Fall back to default namespace;
+			// maybe this was on the old dashboard.
+			key.Namespace = ""
+			err = datastoreClient.Get(c, key, l)
+		}
+		if err != nil {
+			logErr(w, r, err)
+			return
+		}
+	}
+	b, err := l.Text()
+	if err != nil {
+		logErr(w, r, err)
+		return
+	}
+	w.Write(b)
+}
+
+// clearResultsHandler purge a single build failure from the dashboard.
+// It currently only supports the main Go repo.
+func clearResultsHandler(r *http.Request) (interface{}, error) {
+	if r.Method != "POST" {
+		return nil, errBadMethod(r.Method)
+	}
+	builder := r.FormValue("builder")
+	hash := r.FormValue("hash")
+	if builder == "" {
+		return nil, errors.New("missing 'builder'")
+	}
+	if hash == "" {
+		return nil, errors.New("missing 'hash'")
+	}
+
+	ctx := r.Context()
+
+	_, err := datastoreClient.RunInTransaction(ctx, func(tx *datastore.Transaction) error {
+		c := &Commit{
+			PackagePath: "", // TODO(adg): support clearing sub-repos
+			Hash:        hash,
+		}
+		err := tx.Get(c.Key(), c)
+		err = filterDatastoreError(err)
+		if err == datastore.ErrNoSuchEntity {
+			// Doesn't exist, so no build to clear.
+			return nil
+		}
+		if err != nil {
+			return err
+		}
+
+		r := c.Result(builder, "")
+		if r == nil {
+			// No result, so nothing to clear.
+			return nil
+		}
+		c.RemoveResult(r)
+		_, err = tx.Put(c.Key(), c)
+		if err != nil {
+			return err
+		}
+		return tx.Delete(r.Key())
+	})
+	return nil, err
+}
+
+type dashHandler func(*http.Request) (interface{}, error)
+
+type dashResponse struct {
+	Response interface{}
+	Error    string
+}
+
+// errBadMethod is returned by a dashHandler when
+// the request has an unsuitable method.
+type errBadMethod string
+
+func (e errBadMethod) Error() string {
+	return "bad method: " + string(e)
+}
+
+func builderKeyRevoked(builder string) bool {
+	switch builder {
+	case "plan9-amd64-mischief":
+		// Broken and unmaintained for months.
+		// It's polluting the dashboard.
+		return true
+	case "linux-arm-onlinenet":
+		// Requested to be revoked by Dave Cheney.
+		// The machine is in a fail+report loop
+		// and can't be accessed. Revoke it for now.
+		return true
+	}
+	return false
+}
+
+// AuthHandler wraps a http.HandlerFunc with a handler that validates the
+// supplied key and builder query parameters.
+func AuthHandler(h dashHandler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		c := r.Context()
+
+		// Put the URL Query values into r.Form to avoid parsing the
+		// request body when calling r.FormValue.
+		r.Form = r.URL.Query()
+
+		var err error
+		var resp interface{}
+
+		// Validate key query parameter for POST requests only.
+		key := r.FormValue("key")
+		builder := r.FormValue("builder")
+		if r.Method == "POST" && !validKey(c, key, builder) {
+			err = fmt.Errorf("invalid key %q for builder %q", key, builder)
+		}
+
+		// Call the original HandlerFunc and return the response.
+		if err == nil {
+			resp, err = h(r)
+		}
+
+		// Write JSON response.
+		dashResp := &dashResponse{Response: resp}
+		if err != nil {
+			log.Printf("%v", err)
+			dashResp.Error = err.Error()
+		}
+		w.Header().Set("Content-Type", "application/json")
+		if err = json.NewEncoder(w).Encode(dashResp); err != nil {
+			log.Printf("encoding response: %v", err)
+		}
+	})
+}
+
+// validHash reports whether hash looks like a valid git commit hash.
+func validHash(hash string) bool {
+	// TODO: correctly validate a hash: check that it's exactly 40
+	// lowercase hex digits. But this is what we historically did:
+	return hash != ""
+}
+
+func validKey(c context.Context, key, builder string) bool {
+	if isMasterKey(c, key) {
+		return true
+	}
+	if builderKeyRevoked(builder) {
+		return false
+	}
+	return key == builderKey(c, builder)
+}
+
+func isMasterKey(ctx context.Context, k string) bool {
+	return k == masterKey
+}
+
+func builderKey(ctx context.Context, builder string) string {
+	h := hmac.New(md5.New, []byte(masterKey))
+	h.Write([]byte(builder))
+	return fmt.Sprintf("%x", h.Sum(nil))
+}
+
+func logErr(w http.ResponseWriter, r *http.Request, err error) {
+	log.Printf("Error: %v", err)
+	w.WriteHeader(http.StatusInternalServerError)
+	fmt.Fprint(w, "Error: ", html.EscapeString(err.Error()))
+}
+
+// limitStringLength essentially does return s[:max],
+// but it ensures that we dot not split UTF-8 rune in half.
+// Otherwise appengine python scripts will break badly.
+func limitStringLength(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	for {
+		s = s[:max]
+		r, size := utf8.DecodeLastRuneInString(s)
+		if r != utf8.RuneError || size != 1 {
+			return s
+		}
+		max--
+	}
+}
diff --git a/cmd/coordinator/internal/legacydash/static/status_alert.gif b/cmd/coordinator/internal/legacydash/static/status_alert.gif
new file mode 100644
index 0000000..495d9d2
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/static/status_alert.gif
Binary files differ
diff --git a/cmd/coordinator/internal/legacydash/static/status_good.gif b/cmd/coordinator/internal/legacydash/static/status_good.gif
new file mode 100644
index 0000000..ef9c5a8
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/static/status_good.gif
Binary files differ
diff --git a/cmd/coordinator/internal/legacydash/static/style.css b/cmd/coordinator/internal/legacydash/static/style.css
new file mode 100644
index 0000000..34c6603
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/static/style.css
@@ -0,0 +1,317 @@
+* { box-sizing: border-box; }
+
+.dashboards {
+  padding: 0.5em;
+}
+.dashboards > a {
+  padding: 0.5em;
+  background: #eee;
+  color: blue;
+}
+
+body {
+  margin: 0;
+  font-family: sans-serif;
+  padding: 0; margin: 0;
+  color: #222;
+  display: inline-block;
+  min-width: 100%;
+}
+
+.container {
+  max-width: 900px;
+  margin: 0 auto;
+}
+
+p, pre, ul, ol { margin: 20px; }
+
+h1, h2, h3, h4 {
+  margin: 20px 0;
+  padding: 0;
+  color: #375EAB;
+  font-weight: bold;
+}
+ 
+h1 { font-size: 24px; }
+h2 { font-size: 20px; }
+h3 { font-size: 20px; }
+h4 { font-size: 16px; }
+ 
+h2 { background: #E0EBF5; padding: 2px 5px; }
+h3, h4 { margin: 20px 5px; }
+ 
+dl, dd { font-size: 14px; }
+dl { margin: 20px; }
+dd { margin: 2px 20px; }
+ 
+.clear {
+  clear: both;
+}
+ 
+.button {
+  padding: 10px;
+  
+  color: #222;
+  border: 1px solid #375EAB;
+  background: #E0EBF5;
+  
+  border-radius: 5px;
+  
+  cursor: pointer;
+  
+  margin-left: 60px;
+}
+ 
+/* navigation bar */
+ 
+#topbar {
+  padding: 10px 10px;
+  background: #E0EBF5;
+}
+ 
+#topbar a {
+  color: #222;
+}
+#topbar h1 {
+  float: left;
+  margin: 0;
+  padding-top: 5px;
+}
+ 
+#topbar nav { 
+  float: left;
+  margin-left: 20px;
+}
+#topbar 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;
+  -webkit-border-radius: 5px;
+  -moz-border-radius: 5px;
+  border-radius: 5px;
+}
+ 
+.page {
+  margin-top: 20px;
+}
+ 
+/* settings panels */
+aside {
+  margin-top: 5px;
+}
+ 
+.panel {
+  border: 1px solid #aaa;
+  border-radius: 5px;
+  margin-bottom: 5px;
+}
+ 
+.panel h1 {
+  font-size: 16px;
+  margin: 0;
+  padding: 2px 8px;
+}
+ 
+.panel select {
+  padding: 5px;
+  border: 0;
+  width: 100%;
+}
+ 
+/* results table */
+ 
+table {
+  margin: 5px;
+  border-collapse: collapse;
+  font-size: 11px;
+}
+ 
+table td, table th, table td, table th {
+  vertical-align: top;
+  padding: 2px 6px;
+}
+ 
+table tr:nth-child(2n+1) {
+  background: #F4F4F4;
+}
+
+table tr.commit:hover {
+  background-color: #ffff99 !important;
+}
+ 
+table thead tr {
+  background: #fff !important;
+}
+ 
+/* build results */
+ 
+.build td, .build th, .packages td, .packages th {
+  vertical-align: top;
+  padding: 2px 4px;
+  font-size: 10pt;
+}
+      
+.build .hash {
+  font-family: monospace;
+  font-size: 9pt;
+}
+ 
+.build .result {
+  text-align: center;
+  width: 2em;
+}
+ 
+.build .col-desc, .build .col-result, .build .col-metric, .build .col-numresults {
+  border-right: 1px solid #ccc;
+}
+
+.build .row-commit {
+  border-top: 2px solid #ccc;
+}
+
+.build .arch {
+  font-size: 83%;
+  font-weight: normal;
+}
+
+.build .time {
+  color: #666;
+}
+
+.build .ok {
+  font-size: 83%;
+}
+
+.build .desc, .build .time, .build .user {
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+ 
+.build .desc {
+  max-width: 150px;
+}
+
+.build .user {
+  max-width: 50px;
+}
+
+tr.subheading2 th {
+  max-width: 4em;
+  overflow: hidden;
+  word-wrap: none;
+}
+ 
+.good   { text-decoration: none; color: #000000; border: 2px solid #00E700}
+.bad    { text-decoration: none; text-shadow: 1px 1px 0 #000000; color: #FFFFFF; background: #E70000;}
+.noise  { text-decoration: none; color: #888; }
+.fail   { color: #C00; }
+
+/* pagination */
+ 
+.paginate nav {
+  padding: 0.5em;
+  margin: 10px 0;
+}
+ 
+.paginate nav a {
+  padding: 0.5em;
+  background: #E0EBF5;
+  color: blue;
+
+  -webkit-border-radius: 5px;
+  -moz-border-radius: 5px;
+  border-radius: 5px;
+}
+ 
+.paginate nav a.inactive {
+  color: #888;
+  cursor: default;
+  text-decoration: none;
+}
+
+/* diffs */
+ 
+.diff-meta {
+  font-family: monospace;
+  margin-bottom: 10px;
+}
+ 
+.diff-container {
+  padding: 10px;
+}
+ 
+.diff table .metric {
+  font-weight: bold;
+}
+ 
+.diff {
+  border: 1px solid #aaa;
+  border-radius: 5px;
+  margin-bottom: 5px;
+  margin-right: 10px;
+  float: left;
+}
+ 
+.diff h1 {
+  font-size: 16px;
+  margin: 0;
+  padding: 2px 8px;
+}
+
+/* positioning elements */
+ 
+.page {
+  position: relative;
+  width: 100%;
+}
+ 
+aside {
+  position: absolute;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  width: 200px;
+}
+ 
+.main-content {
+  position: absolute;
+  top: 0;
+  left: 210px;
+  right: 5px;
+  min-height: 200px;
+  overflow: hidden;
+}
+ 
+@media only screen and (max-width: 900px) {
+  aside {
+    position: relative;
+    display: block;
+    width: auto;
+  }
+ 
+  .main-content {
+    position: static;
+    padding: 0;
+  }
+  
+  aside .panel {
+    float: left;
+    width: auto;
+    margin-right: 5px;
+  }
+  aside .button {
+    float: left;
+    margin: 0;
+  }
+}
diff --git a/cmd/coordinator/internal/legacydash/ui.go b/cmd/coordinator/internal/legacydash/ui.go
new file mode 100644
index 0000000..6a24a46
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/ui.go
@@ -0,0 +1,965 @@
+// Copyright 2011 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.
+
+//go:build go1.16
+// +build go1.16
+
+package legacydash
+
+import (
+	"bytes"
+	"context"
+	_ "embed"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"html/template"
+	"log"
+	"net/http"
+	"os"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"cloud.google.com/go/datastore"
+	"golang.org/x/build/dashboard"
+	"golang.org/x/build/maintner/maintnerd/apipb"
+	"golang.org/x/build/repos"
+	"golang.org/x/build/types"
+	"golang.org/x/sync/errgroup"
+	"grpc.go4.org"
+	"grpc.go4.org/codes"
+)
+
+// uiHandler is the HTTP handler for the https://build.golang.org/.
+func uiHandler(w http.ResponseWriter, r *http.Request) {
+	view, err := viewForRequest(r)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	dashReq, err := dashboardRequest(view, r)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	ctx := r.Context()
+	tb := &uiTemplateDataBuilder{
+		view: view,
+		req:  dashReq,
+	}
+	var rpcs errgroup.Group
+	rpcs.Go(func() error {
+		var err error
+		tb.res, err = maintnerClient.GetDashboard(ctx, dashReq)
+		return err
+	})
+	if view.ShowsActiveBuilds() {
+		rpcs.Go(func() error {
+			tb.activeBuilds = getActiveBuilds(ctx)
+			return nil
+		})
+	}
+	if err := rpcs.Wait(); err != nil {
+		http.Error(w, "maintner.GetDashboard: "+err.Error(), httpStatusOfErr(err))
+		return
+	}
+	data, err := tb.buildTemplateData(ctx)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	view.ServeDashboard(w, r, data)
+}
+
+// dashboardView is something that can render uiTemplateData.
+// See viewForRequest.
+type dashboardView interface {
+	ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData)
+	// ShowsActiveBuilds reports whether this view uses
+	// information about the currently active builds.
+	ShowsActiveBuilds() bool
+}
+
+// viewForRequest selects the dashboardView based on the HTTP
+// request's "mode" parameter. Any error should be considered
+// an HTTP 400 Bad Request.
+func viewForRequest(r *http.Request) (dashboardView, error) {
+	if r.Method != "GET" && r.Method != "HEAD" {
+		return nil, errors.New("unsupported method")
+	}
+	switch r.FormValue("mode") {
+	case "failures":
+		return failuresView{}, nil
+	case "json":
+		return jsonView{}, nil
+	case "":
+		return htmlView{}, nil
+	}
+	return nil, errors.New("unsupported mode argument")
+}
+
+type commitInPackage struct {
+	packagePath string // "" for Go, else package import path
+	commit      string // git commit hash
+}
+
+// uiTemplateDataBuilder builds the uiTemplateData used by the various
+// dashboardViews. That is, it maps the maintner protobuf response to
+// the data structure needed by the dashboardView/template.
+type uiTemplateDataBuilder struct {
+	view         dashboardView
+	req          *apipb.DashboardRequest
+	res          *apipb.DashboardResponse
+	activeBuilds []types.ActivePostSubmitBuild // optional; for blue gopher links
+
+	// testCommitData, if non-nil, provides an alternate data
+	// source to use for testing instead of making real datastore
+	// calls. The keys are stringified datastore.Keys.
+	testCommitData map[string]*Commit
+}
+
+// getCommitsToLoad returns a set (all values are true) of which commits to load
+// from the datastore. If fakeResults is on, it returns an empty set.
+func (tb *uiTemplateDataBuilder) getCommitsToLoad() map[commitInPackage]bool {
+	if fakeResults {
+		return nil
+	}
+	m := make(map[commitInPackage]bool)
+	add := func(packagePath, commit string) {
+		m[commitInPackage{packagePath: packagePath, commit: commit}] = true
+	}
+
+	for _, dc := range tb.res.Commits {
+		add(tb.req.Repo, dc.Commit)
+	}
+	// We also want to load the Commits for the x/repo heads.
+	if tb.showXRepoSection() {
+		for _, rh := range tb.res.RepoHeads {
+			if path := repoImportPath(rh); path != "" {
+				add(path, rh.Commit.Commit)
+			}
+		}
+	}
+	return m
+}
+
+// loadDatastoreCommits loads the commits given in the keys of the
+// want map. The returned map is keyed by the git hash and may not
+// contain items that didn't exist in the datastore. (It is not an
+// error if 1 or all don't exist.)
+func (tb *uiTemplateDataBuilder) loadDatastoreCommits(ctx context.Context, want map[commitInPackage]bool) (map[string]*Commit, error) {
+	ret := map[string]*Commit{}
+
+	// Allow tests to fake what the datastore would've loaded, and
+	// thus also allow tests to be run without a real (or fake) datastore.
+	if m := tb.testCommitData; m != nil {
+		for k := range want {
+			if c, ok := m[k.commit]; ok {
+				ret[k.commit] = c
+			}
+		}
+		return ret, nil
+	}
+
+	var keys []*datastore.Key
+	for k := range want {
+		key := (&Commit{
+			PackagePath: k.packagePath,
+			Hash:        k.commit,
+		}).Key()
+		keys = append(keys, key)
+	}
+	commits, err := fetchCommits(ctx, keys)
+	if err != nil {
+		return nil, fmt.Errorf("fetchCommits: %v", err)
+	}
+	for _, c := range commits {
+		ret[c.Hash] = c
+	}
+	return ret, nil
+}
+
+// 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 + ">"
+}
+
+// newCommitInfo returns a new CommitInfo populated for the template
+// data given a repo name and a dashboard commit from that repo, using
+// previously loaded datastore commit info in tb.
+func (tb *uiTemplateDataBuilder) newCommitInfo(dsCommits map[string]*Commit, repo string, dc *apipb.DashCommit) *CommitInfo {
+	branch := dc.Branch
+	if branch == "" {
+		branch = "master"
+	}
+	ci := &CommitInfo{
+		Hash:        dc.Commit,
+		PackagePath: repo,
+		User:        formatGitAuthor(dc.AuthorName, dc.AuthorEmail),
+		Desc:        cleanTitle(dc.Title, tb.req.Branch),
+		Time:        time.Unix(dc.CommitTimeSec, 0),
+		Branch:      branch,
+	}
+	if dsc, ok := dsCommits[dc.Commit]; ok {
+		ci.ResultData = dsc.ResultData
+	}
+	// For non-go repos, add the rows for the Go commits that were
+	// at HEAD overlapping in time with dc.Commit.
+	if !tb.isGoRepo() {
+		if dc.GoCommitAtTime != "" {
+			ci.addEmptyResultGoHash(dc.GoCommitAtTime)
+		}
+		if dc.GoCommitLatest != "" && dc.GoCommitLatest != dc.GoCommitAtTime {
+			ci.addEmptyResultGoHash(dc.GoCommitLatest)
+		}
+	}
+	return ci
+}
+
+// showXRepoSection reports whether the dashboard should show the state of the x/foo repos at the bottom of
+// the page in the three branches (master, latest release branch, two releases ago).
+func (tb *uiTemplateDataBuilder) showXRepoSection() bool {
+	return tb.req.Page == 0 &&
+		(tb.branch() == "master" || tb.req.Branch == "mixed") &&
+		tb.isGoRepo()
+}
+
+func (tb *uiTemplateDataBuilder) isGoRepo() bool { return tb.req.Repo == "" || tb.req.Repo == "go" }
+
+// repoGerritProj returns the Gerrit project name on go.googlesource.com for
+// the repo requested, or empty if unknown.
+func (tb *uiTemplateDataBuilder) repoGerritProj() string {
+	if tb.isGoRepo() {
+		return "go"
+	}
+	if r, ok := repos.ByImportPath[tb.req.Repo]; ok {
+		return r.GoGerritProject
+	}
+	return ""
+}
+
+// branch returns the request branch, or "master" if empty.
+func (tb *uiTemplateDataBuilder) branch() string {
+	if tb.req.Branch == "" {
+		return "master"
+	}
+	return tb.req.Branch
+}
+
+// repoImportPath returns the import path for rh, unless rh is the
+// main "go" repo or is configured to be hidden from the dashboard, in
+// which case it returns the empty string.
+func repoImportPath(rh *apipb.DashRepoHead) string {
+	if rh.GerritProject == "go" {
+		return ""
+	}
+	ri, ok := repos.ByGerritProject[rh.GerritProject]
+	if !ok || !ri.ShowOnDashboard() {
+		return ""
+	}
+	return ri.ImportPath
+}
+
+func (tb *uiTemplateDataBuilder) buildTemplateData(ctx context.Context) (*uiTemplateData, error) {
+	dsCommits, err := tb.loadDatastoreCommits(ctx, tb.getCommitsToLoad())
+	if err != nil {
+		return nil, err
+	}
+
+	var commits []*CommitInfo
+	for _, dc := range tb.res.Commits {
+		ci := tb.newCommitInfo(dsCommits, tb.req.Repo, dc)
+		commits = append(commits, ci)
+	}
+
+	// x/ repo sections at bottom (each is a "TagState", for historical reasons)
+	var xRepoSections []*TagState
+	if tb.showXRepoSection() {
+		for _, gorel := range tb.res.Releases {
+			ts := &TagState{
+				Name: gorel.BranchName,
+				Tag: &CommitInfo{ // only a minimally populated version is needed by the template
+					Hash: gorel.BranchCommit,
+				},
+			}
+			for _, rh := range tb.res.RepoHeads {
+				path := repoImportPath(rh)
+				if path == "" {
+					continue
+				}
+				ts.Packages = append(ts.Packages, &PackageState{
+					Package: &Package{
+						Name: rh.GerritProject,
+						Path: path,
+					},
+					Commit: tb.newCommitInfo(dsCommits, path, rh.Commit),
+				})
+			}
+			builders := map[string]bool{}
+			for _, pkg := range ts.Packages {
+				addBuilders(builders, pkg.Package.Name, ts.Branch())
+			}
+			ts.Builders = builderKeys(builders)
+
+			sort.Slice(ts.Packages, func(i, j int) bool {
+				return ts.Packages[i].Package.Name < ts.Packages[j].Package.Name
+			})
+			xRepoSections = append(xRepoSections, ts)
+		}
+	}
+
+	// Release Branches
+	var releaseBranches []string
+	for _, gr := range tb.res.Releases {
+		if gr.BranchName != "master" {
+			releaseBranches = append(releaseBranches, gr.BranchName)
+		}
+	}
+
+	gerritProject := "go"
+	if repo := repos.ByImportPath[tb.req.Repo]; repo != nil {
+		gerritProject = repo.GoGerritProject
+	}
+
+	data := &uiTemplateData{
+		Dashboard:  goDash,
+		Package:    goDash.packageWithPath(tb.req.Repo),
+		Commits:    commits,
+		TagState:   xRepoSections,
+		Pagination: &Pagination{},
+		Branches:   tb.res.Branches,
+		Branch:     tb.req.Branch,
+		Repo:       gerritProject,
+	}
+
+	builders := buildersOfCommits(commits)
+	if tb.branch() == "mixed" {
+		for _, gr := range tb.res.Releases {
+			addBuilders(builders, tb.repoGerritProj(), gr.BranchName)
+		}
+	} else {
+		addBuilders(builders, tb.repoGerritProj(), tb.branch())
+	}
+	data.Builders = builderKeys(builders)
+
+	if tb.res.CommitsTruncated {
+		data.Pagination.Next = int(tb.req.Page) + 1
+	}
+	if tb.req.Page > 0 {
+		data.Pagination.Prev = int(tb.req.Page) - 1
+		data.Pagination.HasPrev = true
+	}
+
+	if tb.view.ShowsActiveBuilds() {
+		// Populate building URLs for the HTML UI only.
+		data.populateBuildingURLs(ctx, tb.activeBuilds)
+	}
+
+	return data, nil
+}
+
+// htmlView renders the HTML (default) form of https://build.golang.org/ with no mode parameter.
+type htmlView struct{}
+
+func (htmlView) ShowsActiveBuilds() bool { return true }
+func (htmlView) ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData) {
+	var buf bytes.Buffer
+	if err := uiTemplate.Execute(&buf, data); err != nil {
+		logErr(w, r, err)
+		return
+	}
+	buf.WriteTo(w)
+}
+
+// dashboardRequest is a pure function that maps the provided HTTP
+// request to a maintner DashboardRequest and lightly validates the
+// HTTP request for the root dashboard handler. (It does not validate
+// that, say, branches or repos are valid.)
+// Any returned error is an HTTP 400 Bad Request.
+func dashboardRequest(view dashboardView, r *http.Request) (*apipb.DashboardRequest, error) {
+	page := 0
+	if s := r.FormValue("page"); s != "" {
+		var err error
+		page, err = strconv.Atoi(r.FormValue("page"))
+		if err != nil {
+			return nil, fmt.Errorf("invalid page value %q", s)
+		}
+		if page < 0 {
+			return nil, errors.New("negative page")
+		}
+	}
+
+	repo := r.FormValue("repo") // empty for main go repo, else e.g. "golang.org/x/net"
+
+	branch := r.FormValue("branch")
+	if branch == "" {
+		branch = "master"
+	}
+	return &apipb.DashboardRequest{
+		Page:       int32(page),
+		Branch:     branch,
+		Repo:       repo,
+		MaxCommits: commitsPerPage,
+	}, nil
+}
+
+// cleanTitle returns a cleaned version of the provided title for
+// users viewing the provided viewBranch.
+func cleanTitle(title, viewBranch string) string {
+	// Don't rewrite anything for master and mixed.
+	if viewBranch == "master" || viewBranch == "mixed" {
+		return title
+	}
+	// Strip the "[release-branch.go1.n]" prefixes from commit messages
+	// when looking at a branch.
+	if strings.HasPrefix(title, "[") {
+		if i := strings.IndexByte(title, ']'); i != -1 {
+			return strings.TrimSpace(title[i+1:])
+		}
+	}
+	return title
+}
+
+// failuresView renders https://build.golang.org/?mode=failures, where it outputs
+// one line per failure on the front page, in the form:
+//    hash builder failure-url
+type failuresView struct{}
+
+func (failuresView) ShowsActiveBuilds() bool { return false }
+func (failuresView) ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData) {
+	w.Header().Set("Content-Type", "text/plain")
+	for _, c := range data.Commits {
+		for _, b := range data.Builders {
+			res := c.Result(b, "")
+			if res == nil || res.OK || res.LogHash == "" {
+				continue
+			}
+			url := fmt.Sprintf("https://%v/log/%v", r.Host, res.LogHash)
+			fmt.Fprintln(w, c.Hash, b, url)
+		}
+	}
+	// TODO: this doesn't include the TagState commit. It would be
+	// needed if we want to do golang.org/issue/36131, to permit
+	// the retrybuilds command to wipe flaky non-go builds.
+}
+
+// jsonView renders https://build.golang.org/?mode=json.
+// The output is a types.BuildStatus JSON object.
+type jsonView struct{}
+
+func (jsonView) ShowsActiveBuilds() bool { return false }
+func (jsonView) ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData) {
+	res := toBuildStatus(r.Host, data)
+	v, _ := json.MarshalIndent(res, "", "\t")
+	w.Header().Set("Content-Type", "text/json; charset=utf-8")
+	w.Write(v)
+}
+
+func toBuildStatus(host string, data *uiTemplateData) types.BuildStatus {
+	// cell returns one of "" (no data), "ok", or a failure URL.
+	cell := func(res *Result) string {
+		switch {
+		case res == nil:
+			return ""
+		case res.OK:
+			return "ok"
+		}
+		return fmt.Sprintf("https://%v/log/%v", host, res.LogHash)
+	}
+
+	builders := data.allBuilders()
+
+	var res types.BuildStatus
+	res.Builders = builders
+
+	// First the commits from the main section (the requested repo)
+	for _, c := range data.Commits {
+		// The logic below works for both the go repo and other subrepos: if c is
+		// in the main go repo, ResultGoHashes returns a slice of length 1
+		// containing the empty string.
+		for _, h := range c.ResultGoHashes() {
+			rev := types.BuildRevision{
+				Repo:       data.Repo,
+				Results:    make([]string, len(res.Builders)),
+				GoRevision: h,
+			}
+			commitToBuildRevision(c, &rev)
+			for i, b := range res.Builders {
+				rev.Results[i] = cell(c.Result(b, h))
+			}
+			res.Revisions = append(res.Revisions, rev)
+		}
+	}
+
+	// Then the one commit each for the subrepos for each of the tracked tags.
+	// (tip, Go 1.4, etc)
+	for _, ts := range data.TagState {
+		for _, pkgState := range ts.Packages {
+			goRev := ts.Tag.Hash
+			goBranch := ts.Name
+			if goBranch == "tip" {
+				// Normalize old hg terminology into
+				// our git branch name.
+				goBranch = "master"
+			}
+			rev := types.BuildRevision{
+				Repo:       pkgState.Package.Name,
+				GoRevision: goRev,
+				Results:    make([]string, len(res.Builders)),
+				GoBranch:   goBranch,
+			}
+			commitToBuildRevision(pkgState.Commit, &rev)
+			for i, b := range res.Builders {
+				rev.Results[i] = cell(pkgState.Commit.Result(b, goRev))
+			}
+			res.Revisions = append(res.Revisions, rev)
+		}
+	}
+	return res
+}
+
+// commitToBuildRevision fills in the fields of BuildRevision rev that
+// are derived from Commit c.
+func commitToBuildRevision(c *CommitInfo, rev *types.BuildRevision) {
+	rev.Revision = c.Hash
+	rev.Date = c.Time.Format(time.RFC3339)
+	rev.Author = c.User
+	rev.Desc = c.Desc
+	rev.Branch = c.Branch
+}
+
+type Pagination struct {
+	Next, Prev int
+	HasPrev    bool
+}
+
+// fetchCommits loads any commits that exist given by keys.
+// It is not an error if a commit doesn't exist.
+// Only commits that were found in datastore are returned,
+// in an unspecified order.
+func fetchCommits(ctx context.Context, keys []*datastore.Key) ([]*Commit, error) {
+	if len(keys) == 0 {
+		return nil, nil
+	}
+	out := make([]*Commit, len(keys))
+	for i := range keys {
+		out[i] = new(Commit)
+	}
+
+	err := datastoreClient.GetMulti(ctx, keys, out)
+	err = filterDatastoreError(err)
+	err = filterNoSuchEntity(err)
+	if err != nil {
+		return nil, err
+	}
+	filtered := out[:0]
+	for _, c := range out {
+		if c.Valid() { // that is, successfully loaded
+			filtered = append(filtered, c)
+		}
+	}
+	return filtered, nil
+}
+
+// buildersOfCommits returns the set of builders that provided
+// Results for the provided commits.
+func buildersOfCommits(commits []*CommitInfo) map[string]bool {
+	m := make(map[string]bool)
+	for _, commit := range commits {
+		for _, r := range commit.Results() {
+			if r.Builder != "" {
+				m[r.Builder] = true
+			}
+		}
+	}
+	return m
+}
+
+// addBuilders adds builders to the provide map that should be active for
+// the named Gerrit project & branch. (Issue 19930)
+func addBuilders(builders map[string]bool, gerritProj, branch string) {
+	for name, bc := range dashboard.Builders {
+		if bc.BuildsRepoPostSubmit(gerritProj, branch, branch) {
+			builders[name] = true
+		}
+	}
+}
+
+func builderKeys(m map[string]bool) (s []string) {
+	s = make([]string, 0, len(m))
+	for k := range m {
+		s = append(s, k)
+	}
+	sort.Sort(builderOrder(s))
+	return
+}
+
+// builderOrder implements sort.Interface, sorting builder names
+// ("darwin-amd64", etc) first by builderPriority and then alphabetically.
+type builderOrder []string
+
+func (s builderOrder) Len() int      { return len(s) }
+func (s builderOrder) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
+func (s builderOrder) Less(i, j int) bool {
+	pi, pj := builderPriority(s[i]), builderPriority(s[j])
+	if pi == pj {
+		return s[i] < s[j]
+	}
+	return pi < pj
+}
+
+func builderPriority(builder string) (p int) {
+	// Group race builders together.
+	if isRace(builder) {
+		return 2
+	}
+	// If the OS has a specified priority, use it.
+	if p, ok := osPriority[builderOS(builder)]; ok {
+		return p
+	}
+	// The rest.
+	return 10
+}
+
+func isRace(s string) bool {
+	return strings.Contains(s, "-race-") || strings.HasSuffix(s, "-race")
+}
+
+func unsupported(builder string) bool {
+	return unsupportedOS(builderOS(builder))
+}
+
+func unsupportedOS(os string) bool {
+	if os == "race" || os == "android" || os == "all" {
+		return false
+	}
+	p, ok := osPriority[os]
+	return !ok || p > 1
+}
+
+// Priorities for specific operating systems.
+var osPriority = map[string]int{
+	"all":     0,
+	"darwin":  1,
+	"freebsd": 1,
+	"linux":   1,
+	"windows": 1,
+	// race == 2
+	"android":   3,
+	"openbsd":   4,
+	"netbsd":    5,
+	"dragonfly": 6,
+}
+
+// TagState represents the state of all Packages at a branch.
+type TagState struct {
+	Name     string      // Go branch name: "master", "release-branch.go1.4", etc
+	Tag      *CommitInfo // current Go commit on the Name branch
+	Packages []*PackageState
+	Builders []string
+}
+
+// Branch returns the git branch name, converting from the old
+// terminology we used from Go's hg days into git terminology.
+func (ts *TagState) Branch() string {
+	if ts.Name == "tip" {
+		return "master"
+	}
+	return ts.Name
+}
+
+// PackageState represents the state of a Package (x/foo repo) for given Go branch.
+type PackageState struct {
+	Package *Package
+	Commit  *CommitInfo
+}
+
+// A CommitInfo is a struct for use by html/template package.
+// It is not stored in the datastore.
+type CommitInfo struct {
+	Hash string
+
+	// ResultData is a copy of the Commit.ResultData field from datastore.
+	ResultData []string
+
+	// BuildingURLs contains the status URL values for builds that
+	// are currently in progress for this commit.
+	BuildingURLs map[builderAndGoHash]string
+
+	PackagePath string    // (empty for main repo commits)
+	User        string    // "Foo Bar <foo@bar.com>"
+	Desc        string    // git commit title
+	Time        time.Time // commit time
+	Branch      string    // "master", "release-branch.go1.14"
+}
+
+// addEmptyResultGoHash adds an empty result containing goHash to
+// ci.ResultData, unless ci already contains a result for that hash.
+// This is used for non-go repos to show the go commits (both earliest
+// and latest) that correspond to this repo's commit time. We add an
+// empty result so it shows up on the dashboard (both for humans, and
+// in JSON form for the coordinator to pick up as work). Once the
+// coordinator does that work and posts its result, then ResultData
+// will be populate and this turns into a no-op.
+func (ci *CommitInfo) addEmptyResultGoHash(goHash string) {
+	for _, exist := range ci.ResultData {
+		if strings.Contains(exist, goHash) {
+			return
+		}
+	}
+	ci.ResultData = append(ci.ResultData, (&Result{GoHash: goHash}).Data())
+}
+
+type uiTemplateData struct {
+	Dashboard  *Dashboard
+	Package    *Package
+	Commits    []*CommitInfo
+	Builders   []string    // builders for just the main section; not the "TagState" sections
+	TagState   []*TagState // x/foo repo overviews at master + last two releases
+	Pagination *Pagination
+	Branches   []string
+	Branch     string
+	Repo       string // the repo gerrit project name. "go" if unspecified in the request.
+}
+
+// getActiveBuilds returns the builds that coordinator is currently doing.
+// This isn't critical functionality so errors are logged but otherwise ignored for now.
+// Once this is merged into the coordinator we won't need to make an RPC to get
+// this info. See https://github.com/golang/go/issues/34744#issuecomment-563398753.
+func getActiveBuilds(ctx context.Context) (builds []types.ActivePostSubmitBuild) {
+	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+	defer cancel()
+	req, _ := http.NewRequest("GET", "https://farmer.golang.org/status/post-submit-active.json", nil)
+	req = req.WithContext(ctx)
+	res, err := http.DefaultClient.Do(req)
+	if err != nil {
+		log.Printf("getActiveBuilds: Do: %v", err)
+		return
+	}
+	defer res.Body.Close()
+	if res.StatusCode != 200 {
+		log.Printf("getActiveBuilds: %v", res.Status)
+		return
+	}
+	if err := json.NewDecoder(res.Body).Decode(&builds); err != nil {
+		log.Printf("getActiveBuilds: JSON decode: %v", err)
+	}
+	return builds
+}
+
+// populateBuildingURLs populates each commit in Commits' buildingURLs map with the
+// URLs of builds which are currently in progress.
+func (td *uiTemplateData) populateBuildingURLs(ctx context.Context, activeBuilds []types.ActivePostSubmitBuild) {
+	// active maps from a build record with its status URL zeroed
+	// out to to the actual value of that status URL.
+	active := map[types.ActivePostSubmitBuild]string{}
+	for _, rec := range activeBuilds {
+		statusURL := rec.StatusURL
+		rec.StatusURL = ""
+		active[rec] = statusURL
+	}
+
+	condAdd := func(c *CommitInfo, rec types.ActivePostSubmitBuild) {
+		su, ok := active[rec]
+		if !ok {
+			return
+		}
+		if c.BuildingURLs == nil {
+			c.BuildingURLs = make(map[builderAndGoHash]string)
+		}
+		c.BuildingURLs[builderAndGoHash{rec.Builder, rec.GoCommit}] = su
+	}
+
+	for _, b := range td.Builders {
+		for _, c := range td.Commits {
+			condAdd(c, types.ActivePostSubmitBuild{Builder: b, Commit: c.Hash})
+		}
+	}
+
+	// Gather pending commits for sub-repos.
+	for _, ts := range td.TagState {
+		goHash := ts.Tag.Hash
+		for _, b := range td.Builders {
+			for _, pkg := range ts.Packages {
+				c := pkg.Commit
+				condAdd(c, types.ActivePostSubmitBuild{
+					Builder:  b,
+					Commit:   c.Hash,
+					GoCommit: goHash,
+				})
+			}
+		}
+	}
+}
+
+// allBuilders returns the list of builders, unified over the main
+// section and any x/foo branch overview (TagState) sections.
+func (td *uiTemplateData) allBuilders() []string {
+	m := map[string]bool{}
+	for _, b := range td.Builders {
+		m[b] = true
+	}
+	for _, ts := range td.TagState {
+		for _, b := range ts.Builders {
+			m[b] = true
+		}
+	}
+	return builderKeys(m)
+}
+
+var uiTemplate = template.Must(
+	template.New("ui.html").Funcs(tmplFuncs).Parse(uiHTML),
+)
+
+//go:embed ui.html
+var uiHTML string
+
+var tmplFuncs = template.FuncMap{
+	"builderSpans":       builderSpans,
+	"builderSubheading":  builderSubheading,
+	"builderSubheading2": builderSubheading2,
+	"shortDesc":          shortDesc,
+	"shortHash":          shortHash,
+	"shortUser":          shortUser,
+	"unsupported":        unsupported,
+	"isUntested":         isUntested,
+	"knownIssue":         knownIssue,
+	"formatTime":         formatTime,
+}
+
+func formatTime(t time.Time) string {
+	if t.Year() != time.Now().Year() {
+		return t.Format("02 Jan 06")
+	}
+	return t.Format("02 Jan 15:04")
+}
+
+func splitDash(s string) (string, string) {
+	i := strings.Index(s, "-")
+	if i >= 0 {
+		return s[:i], s[i+1:]
+	}
+	return s, ""
+}
+
+// builderOS returns the os tag for a builder string
+func builderOS(s string) string {
+	os, _ := splitDash(s)
+	return os
+}
+
+// builderOSOrRace returns the builder OS or, if it is a race builder, "race".
+func builderOSOrRace(s string) string {
+	if isRace(s) {
+		return "race"
+	}
+	return builderOS(s)
+}
+
+// builderArch returns the arch tag for a builder string
+func builderArch(s string) string {
+	_, arch := splitDash(s)
+	arch, _ = splitDash(arch) // chop third part
+	return arch
+}
+
+// builderSubheading returns a short arch tag for a builder string
+// or, if it is a race builder, the builder OS.
+func builderSubheading(s string) string {
+	if isRace(s) {
+		return builderOS(s)
+	}
+	return builderArch(s)
+}
+
+// builderSubheading2 returns any third part of a hyphenated builder name.
+// For instance, for "linux-amd64-nocgo", it returns "nocgo".
+// For race builders it returns the empty string.
+func builderSubheading2(s string) string {
+	if isRace(s) {
+		return ""
+	}
+	_, secondThird := splitDash(s)
+	_, third := splitDash(secondThird)
+	return third
+}
+
+type builderSpan struct {
+	N           int
+	OS          string
+	Unsupported bool
+}
+
+// builderSpans creates a list of tags showing
+// the builder's operating system names, spanning
+// the appropriate number of columns.
+func builderSpans(s []string) []builderSpan {
+	var sp []builderSpan
+	for len(s) > 0 {
+		i := 1
+		os := builderOSOrRace(s[0])
+		u := unsupportedOS(os)
+		for i < len(s) && builderOSOrRace(s[i]) == os {
+			i++
+		}
+		sp = append(sp, builderSpan{i, os, u})
+		s = s[i:]
+	}
+	return sp
+}
+
+// shortDesc returns the first line of a description.
+func shortDesc(desc string) string {
+	if i := strings.Index(desc, "\n"); i != -1 {
+		desc = desc[:i]
+	}
+	return limitStringLength(desc, 100)
+}
+
+// shortHash returns a short version of a hash.
+func shortHash(hash string) string {
+	if len(hash) > 7 {
+		hash = hash[:7]
+	}
+	return hash
+}
+
+// shortUser returns a shortened version of a user string.
+func shortUser(user string) string {
+	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 httpStatusOfErr(err error) int {
+	fmt.Fprintf(os.Stderr, "Got error: %#v, code %v\n", err, grpc.Code(err))
+	switch grpc.Code(err) {
+	case codes.NotFound:
+		return http.StatusNotFound
+	case codes.InvalidArgument:
+		return http.StatusBadRequest
+	default:
+		return http.StatusInternalServerError
+	}
+}
diff --git a/cmd/coordinator/internal/legacydash/ui.html b/cmd/coordinator/internal/legacydash/ui.html
new file mode 100644
index 0000000..3ff7ac8
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/ui.html
@@ -0,0 +1,250 @@
+<!DOCTYPE HTML>
+<html>
+  <head>
+    <title>{{$.Dashboard.Name}} Build Dashboard</title>
+    <link rel="stylesheet" href="/static/style.css"/>
+    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script>
+    <script>
+    var showUnsupported = window.location.hash.substr(1) != "short";
+    function redraw() {
+        showUnsupported = !$("#showshort").prop('checked');
+        $('.unsupported')[showUnsupported?'show':'hide']();
+        window.location.hash = showUnsupported?'':'short';
+    }
+    $(document).ready(function() {
+        $("#showshort").attr('checked', !showUnsupported).change(redraw);
+        redraw();
+    })
+    </script>
+  </head>
+
+  <body>
+    <header id="topbar">
+      <h1>Go Dashboard</h1>
+      <div class="clear"></div>
+    </header>
+
+    <form action="." method="GET">
+    <input type="hidden" name="repo" value="{{.Package.Path}}">
+    <nav class="dashboards">
+      {{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>
+    {{with $.Package.Name}}<h2>{{.}}</h2>{{end}}
+
+  <div class="page">
+
+    {{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="col-desc"></colgroup>
+    {{range $.Builders | builderSpans}}
+      <colgroup class="col-result{{if .Unsupported}} unsupported{{end}}" span="{{.N}}"></colgroup>
+    {{end}}
+      <tr>
+        <!-- extra row to make alternating colors use dark for first result -->
+      </tr>
+      <tr>
+    {{if $.Package.Path}}
+        <th colspan="2">revision</th>
+    {{else}}
+        <th>&nbsp;</th>
+    {{end}}
+        <th></th>
+        <th></th>
+        <th></th>
+    {{range $.Builders | builderSpans}}
+        <th {{if .Unsupported}}class="unsupported"{{end}} colspan="{{.N}}">{{.OS}}</th>
+    {{end}}
+      </tr>
+
+      <tr>
+    {{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}}
+        <th class="result arch{{if (unsupported .)}} unsupported{{end}}{{if knownIssue .}} noise{{end}}" title="{{.}}">{{builderSubheading .}}</th>
+    {{end}}
+      </tr>
+
+      <tr class="subheading2">
+        <th {{if $.Package.Path}}colspan="2"{{end}}>&nbsp;</th>
+        <th></th>
+        <th></th>
+        <th></th>
+    {{range $.Builders}}
+        <th class="result arch{{if (unsupported .)}} unsupported{{end}}{{if knownIssue .}} noise{{end}}" title="{{.}}">{{builderSubheading2 .}}</th>
+    {{end}}
+      </tr>
+
+      {{range $c := $.Commits}}
+      {{range $i, $h := $c.ResultGoHashes}}
+        <tr class="commit">
+        {{if $i}}
+          <td>&nbsp;</td>
+        {{if $h}}
+          <td class="hash"><a href="https://go-review.googlesource.com/q/{{$h}}">{{shortHash $h}}</a></td>
+        {{end}}
+          <td>&nbsp;</td>
+          <td>&nbsp;</td>
+          <td>&nbsp;</td>
+        {{else}}
+          <td class="hash"><a href="https://go-review.googlesource.com/q/{{$c.Hash}}">{{shortHash $c.Hash}}</a></td>
+        {{if $h}}
+          <td class="hash"><a href="https://go-review.googlesource.com/q/{{$h}}">{{shortHash $h}}</a></td>
+        {{end}}
+          <td class="user" title="{{$c.User}}">{{shortUser $c.User}}</td>
+          <td class="time">{{formatTime $c.Time}}</td>
+          <td class="desc" title="{{$c.Desc}}">{{shortDesc $c.Desc}}</td>
+        {{end}}
+          {{range $builderName := $.Builders}}
+            <td class="result{{if (unsupported .)}} unsupported{{end}}">
+            {{if and (eq $.Repo "go") (isUntested $builderName "go" $.Branch "")}}•{{else}}
+              {{with $c.Result $builderName $h}}
+                {{if .BuildingURL}}
+                  <a href="{{.BuildingURL}}"><img src="https://golang.org/favicon.ico" height=16 width=16 border=0></a>
+                {{else if .OK}}
+                  <span class="ok{{if knownIssue $builderName}} noise{{end}}">ok</span>
+                {{else if knownIssue $builderName}}
+                  <a href="/log/{{.LogHash}}" class="noise" title="Builder {{$builderName}} has a known issue. See golang.org/issue/{{knownIssue $builderName}}.">fail</a>
+                {{else}}
+                  <a href="/log/{{.LogHash}}" class="fail">fail</a>
+                {{end}}
+              {{else}}
+                &nbsp;
+              {{end}}
+            {{end}}
+            </td>
+          {{end}}
+        </tr>
+      {{end}}
+    {{end}}
+    </table>
+
+    {{with $.Pagination}}
+    <div class="paginate">
+      <nav>
+        <a {{if .HasPrev}}href="?{{with $.Package.Path}}repo={{.}}&{{end}}page={{.Prev}}{{with $.Branch}}&branch={{.}}{{end}}"{{else}}class="inactive"{{end}}>newer</a>
+        <a {{if .Next}}href="?{{with $.Package.Path}}repo={{.}}&{{end}}page={{.Next}}{{with $.Branch}}&branch={{.}}{{end}}"{{else}}class="inactive"{{end}}>older</a>
+        <a {{if .HasPrev}}href=".{{with $.Branch}}?branch={{.}}{{end}}"{{else}}class="inactive"{{end}}>latest</a>
+      </nav>
+    </div>
+    {{end}}
+
+  {{else}}
+    <p>No commits to display. Hm.</p>
+  {{end}}
+
+  {{range $.TagState}}
+    {{$goHash := .Tag.Hash}}
+    {{$goBranch := .Branch}}
+    {{$builders := .Builders}}
+    {{if .Packages}}
+      <h2>
+        golang.org/x repos at Go {{$goBranch}}
+        <small>(<a href="https://go-review.googlesource.com/q/{{.Tag.Hash}}">{{shortHash .Tag.Hash}}</a>)</small>
+      </h2>
+
+      <table class="build">
+      <colgroup class="col-package"></colgroup>
+      <colgroup class="col-hash"></colgroup>
+      <colgroup class="col-user"></colgroup>
+      <colgroup class="col-time"></colgroup>
+      <colgroup class="col-desc"></colgroup>
+      {{range $builders | builderSpans}}
+        <colgroup class="col-result{{if .Unsupported}} unsupported{{end}}" span="{{.N}}"></colgroup>
+      {{end}}
+      <tr>
+        <!-- extra row to make alternating colors use dark for first result -->
+      </tr>
+      <tr>
+        <th></th>
+        <th></th>
+        <th></th>
+        <th></th>
+        <th></th>
+        {{range $builders | builderSpans}}
+          <th {{if .Unsupported}}class="unsupported"{{end}} colspan="{{.N}}">{{.OS}}</th>
+        {{end}}
+      </tr>
+      <tr>
+        <th></th>
+        <th></th>
+        <th></th>
+        <th></th>
+        <th></th>
+        {{range $builders}}
+          <th class="result arch{{if (unsupported .)}} unsupported{{end}}" title="{{.}}">{{builderSubheading .}}</th>
+        {{end}}
+      </tr>
+      <tr class="subheading2">
+        <th>&nbsp;</th>
+        <th></th>
+        <th></th>
+        <th></th>
+        <th></th>
+        {{range $builders}}
+          <th class="result arch{{if (unsupported .)}} unsupported{{end}}" title="{{.}}">{{builderSubheading2 .}}</th>
+        {{end}}
+      </tr>
+    {{range $pkg := .Packages}}
+      <tr class="commit">
+        <td><a title="{{.Package.Path}}" href="?repo={{.Package.Path}}">{{.Package.Name}}</a></td>
+        <td class="hash">
+          {{$h := $pkg.Commit.Hash}}
+          <a href="https://go-review.googlesource.com/q/{{$h}}">{{shortHash $h}}</a>
+        </td>
+        {{with $pkg.Commit}}
+          <td class="user" title="{{.User}}">{{shortUser .User}}</td>
+          <td class="time">{{formatTime .Time}}</td>
+          <td class="desc" title="{{.Desc}}">{{shortDesc .Desc}}</td>
+        {{end}}
+        {{range $builderName := $builders}}
+          <td class="result{{if (unsupported .)}} unsupported{{end}}">
+            {{if isUntested $builderName $pkg.Package.Name "master" $goBranch}}
+              •
+            {{else}}
+              {{with $pkg.Commit.Result $builderName $goHash}}
+                {{if .BuildingURL}}
+                  <a href="{{.BuildingURL}}"><img src="https://golang.org/favicon.ico" height=16 width=16 border=0></a>
+                {{else if .OK}}
+                  <span class="ok">ok</span>
+                {{else}}
+                  <a href="/log/{{.LogHash}}" class="fail">fail</a>
+                {{end}}
+              {{else}}
+                &nbsp;
+              {{end}}
+            {{end}}
+          </td>
+        {{end}}
+      </tr>
+    {{end}}
+    </table>
+   {{end}}
+  {{end}}
+
+  </div>
+  </body>
+</html>
diff --git a/cmd/coordinator/internal/legacydash/ui_test.go b/cmd/coordinator/internal/legacydash/ui_test.go
new file mode 100644
index 0000000..4b76c54
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/ui_test.go
@@ -0,0 +1,544 @@
+// Copyright 2019 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.
+
+//go:build go1.16
+// +build go1.16
+
+package legacydash
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"golang.org/x/build/dashboard"
+	"golang.org/x/build/maintner/maintnerd/apipb"
+	"golang.org/x/build/types"
+)
+
+func TestUITemplateDataBuilder(t *testing.T) {
+	// Thin the list of builders to make this test's data lighter
+	// and require less maintenance keeping it in sync.
+	origBuilders := dashboard.Builders
+	defer func() { dashboard.Builders = origBuilders }()
+	dashboard.Builders = map[string]*dashboard.BuildConfig{
+		"linux-amd64": origBuilders["linux-amd64"],
+		"linux-386":   origBuilders["linux-386"],
+	}
+
+	tests := []struct {
+		name           string                   // test subname
+		view           dashboardView            // one of htmlView{}, jsonView{}, or failuresView{}
+		req            *apipb.DashboardRequest  // what we pretend we sent to maintner
+		res            *apipb.DashboardResponse // what we pretend we got back from maintner
+		testCommitData map[string]*Commit       // what we pretend we loaded from datastore
+		activeBuilds   []types.ActivePostSubmitBuild
+		want           *uiTemplateData // what we're hoping we generated for the view/template
+	}{
+		// Basic test.
+		{
+			name: "html,zero_value_req,no_commits",
+			view: htmlView{},
+			req:  &apipb.DashboardRequest{},
+			res: &apipb.DashboardResponse{
+				Branches: []string{"release.foo", "release.bar", "dev.blah"},
+			},
+			want: &uiTemplateData{
+				Dashboard:  goDash,
+				Repo:       "go",
+				Package:    &Package{Name: "Go", Path: ""},
+				Branches:   []string{"release.foo", "release.bar", "dev.blah"},
+				Builders:   []string{"linux-386", "linux-amd64"},
+				Pagination: &Pagination{},
+			},
+		},
+
+		// Basic test + two commits: one that's in datastore and one that's not.
+		{
+			name: "html,zero_value_req,has_commit",
+			view: htmlView{},
+			req:  &apipb.DashboardRequest{},
+			// Have only one commit load from the datastore:
+			testCommitData: map[string]*Commit{
+				"26957168c4c0cdcc7ca4f0b19d0eb19474d224ac": {
+					PackagePath: "",
+					Hash:        "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+					ResultData: []string{
+						"openbsd-amd64|true||", // pretend openbsd-amd64 passed (and thus exists)
+					},
+				},
+			},
+			activeBuilds: []types.ActivePostSubmitBuild{
+				{Builder: "linux-amd64", Commit: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac", StatusURL: "http://fake-status"},
+			},
+			res: &apipb.DashboardResponse{
+				Branches: []string{"release.foo", "release.bar", "dev.blah"},
+				Commits: []*apipb.DashCommit{
+					// This is the maintner commit response that is in the datastore:
+					{
+						Commit:        "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						AuthorName:    "Foo Bar",
+						AuthorEmail:   "foo@example.com",
+						CommitTimeSec: 1257894001,
+						Title:         "runtime: fix all the bugs",
+						Branch:        "master",
+					},
+					// And another commit that's not in the datastore:
+					{
+						Commit:        "ffffffffffffffffffffffffffffffffffffffff",
+						AuthorName:    "Fancy Fred",
+						AuthorEmail:   "f@eff.tld",
+						CommitTimeSec: 1257894000,
+						Title:         "all: add effs",
+						Branch:        "master",
+					},
+				},
+				CommitsTruncated: true, // pretend there's a page 2
+			},
+			want: &uiTemplateData{
+				Dashboard: goDash,
+				Repo:      "go",
+				Package:   &Package{Name: "Go", Path: ""},
+				Branches:  []string{"release.foo", "release.bar", "dev.blah"},
+				Builders:  []string{"linux-386", "linux-amd64", "openbsd-amd64"},
+				Pagination: &Pagination{
+					Next: 1,
+				},
+				Commits: []*CommitInfo{
+					{
+						Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						User: "Foo Bar <foo@example.com>",
+						Desc: "runtime: fix all the bugs",
+						Time: time.Unix(1257894001, 0),
+						ResultData: []string{
+							"openbsd-amd64|true||",
+						},
+						Branch:       "master",
+						BuildingURLs: map[builderAndGoHash]string{{builder: "linux-amd64"}: "http://fake-status"},
+					},
+					{
+						Hash:   "ffffffffffffffffffffffffffffffffffffffff",
+						User:   "Fancy Fred <f@eff.tld>",
+						Desc:   "all: add effs",
+						Time:   time.Unix(1257894000, 0),
+						Branch: "master",
+					},
+				},
+			},
+		},
+
+		// Test that we generate the TagState (sections at
+		// bottom with the x/foo repo state).
+		{
+			name:           "html,zero_value_req,has_xrepos",
+			view:           htmlView{},
+			req:            &apipb.DashboardRequest{},
+			testCommitData: map[string]*Commit{},
+			res: &apipb.DashboardResponse{
+				Branches: []string{"release.foo", "release.bar", "dev.blah"},
+				Commits: []*apipb.DashCommit{
+					{
+						Commit:        "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						AuthorName:    "Foo Bar",
+						AuthorEmail:   "foo@example.com",
+						CommitTimeSec: 1257894001,
+						Title:         "runtime: fix all the bugs",
+						Branch:        "master",
+					},
+				},
+				Releases: []*apipb.GoRelease{
+					{
+						BranchName:   "master",
+						BranchCommit: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+					},
+					{
+						BranchName:   "release-branch.go1.99",
+						BranchCommit: "ffffffffffffffffffffffffffffffffffffffff",
+					},
+				},
+				RepoHeads: []*apipb.DashRepoHead{
+					{
+						GerritProject: "go",
+						Commit: &apipb.DashCommit{
+							Commit:        "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+							AuthorName:    "Foo Bar",
+							AuthorEmail:   "foo@example.com",
+							CommitTimeSec: 1257894001,
+							Title:         "runtime: fix all the bugs",
+							Branch:        "master",
+						},
+					},
+					{
+						GerritProject: "net",
+						Commit: &apipb.DashCommit{
+							Commit:        "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+							AuthorName:    "Ee Yore",
+							AuthorEmail:   "e@e.net",
+							CommitTimeSec: 1257894001,
+							Title:         "all: fix networking",
+							Branch:        "master",
+						},
+					},
+					{
+						GerritProject: "sys",
+						Commit: &apipb.DashCommit{
+							Commit:        "dddddddddddddddddddddddddddddddddddddddd",
+							AuthorName:    "Sys Tem",
+							AuthorEmail:   "sys@s.net",
+							CommitTimeSec: 1257894001,
+							Title:         "sys: support more systems",
+							Branch:        "master",
+						},
+					},
+				},
+			},
+			want: &uiTemplateData{
+				Dashboard:  goDash,
+				Repo:       "go",
+				Package:    &Package{Name: "Go", Path: ""},
+				Branches:   []string{"release.foo", "release.bar", "dev.blah"},
+				Builders:   []string{"linux-386", "linux-amd64"},
+				Pagination: &Pagination{},
+				Commits: []*CommitInfo{
+					{
+						Hash:   "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						User:   "Foo Bar <foo@example.com>",
+						Desc:   "runtime: fix all the bugs",
+						Time:   time.Unix(1257894001, 0),
+						Branch: "master",
+					},
+				},
+				TagState: []*TagState{
+					{
+						Name:     "master",
+						Tag:      &CommitInfo{Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac"},
+						Builders: []string{"linux-386", "linux-amd64"},
+						Packages: []*PackageState{
+							{
+								Package: &Package{Name: "net", Path: "golang.org/x/net"},
+								Commit: &CommitInfo{
+									Hash:        "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+									PackagePath: "golang.org/x/net",
+									User:        "Ee Yore <e@e.net>",
+									Desc:        "all: fix networking",
+									Time:        time.Unix(1257894001, 0),
+									Branch:      "master",
+								},
+							},
+							{
+								Package: &Package{Name: "sys", Path: "golang.org/x/sys"},
+								Commit: &CommitInfo{
+									Hash:        "dddddddddddddddddddddddddddddddddddddddd",
+									PackagePath: "golang.org/x/sys",
+									User:        "Sys Tem <sys@s.net>",
+									Desc:        "sys: support more systems",
+									Time:        time.Unix(1257894001, 0),
+									Branch:      "master",
+								},
+							},
+						},
+					},
+					{
+						Name:     "release-branch.go1.99",
+						Tag:      &CommitInfo{Hash: "ffffffffffffffffffffffffffffffffffffffff"},
+						Builders: []string{"linux-386", "linux-amd64"},
+						Packages: []*PackageState{
+							{
+								Package: &Package{Name: "net", Path: "golang.org/x/net"},
+								Commit: &CommitInfo{
+									Hash:        "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+									PackagePath: "golang.org/x/net",
+									User:        "Ee Yore <e@e.net>",
+									Desc:        "all: fix networking",
+									Time:        time.Unix(1257894001, 0),
+									Branch:      "master",
+								},
+							},
+							{
+								Package: &Package{Name: "sys", Path: "golang.org/x/sys"},
+								Commit: &CommitInfo{
+									Hash:        "dddddddddddddddddddddddddddddddddddddddd",
+									PackagePath: "golang.org/x/sys",
+									User:        "Sys Tem <sys@s.net>",
+									Desc:        "sys: support more systems",
+									Time:        time.Unix(1257894001, 0),
+									Branch:      "master",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+
+		// Test viewing a non-go repo.
+		{
+			name:           "html,other_repo",
+			view:           htmlView{},
+			req:            &apipb.DashboardRequest{Repo: "golang.org/x/net"},
+			testCommitData: map[string]*Commit{},
+			res: &apipb.DashboardResponse{
+				Branches: []string{"master", "dev.blah"},
+				Commits: []*apipb.DashCommit{
+					{
+						Commit:         "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						AuthorName:     "Foo Bar",
+						AuthorEmail:    "foo@example.com",
+						CommitTimeSec:  1257894001,
+						Title:          "net: fix all the bugs",
+						Branch:         "master",
+						GoCommitAtTime: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+						GoCommitLatest: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+					},
+				},
+			},
+			want: &uiTemplateData{
+				Dashboard:  goDash,
+				Repo:       "net",
+				Package:    &Package{Name: "net", Path: "golang.org/x/net"},
+				Branches:   []string{"master", "dev.blah"},
+				Builders:   []string{"linux-386", "linux-amd64"},
+				Pagination: &Pagination{},
+				Commits: []*CommitInfo{
+					{
+						PackagePath: "golang.org/x/net",
+						Hash:        "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						User:        "Foo Bar <foo@example.com>",
+						Desc:        "net: fix all the bugs",
+						Time:        time.Unix(1257894001, 0),
+						Branch:      "master",
+						ResultData: []string{
+							"|false||aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+							"|false||bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			tb := &uiTemplateDataBuilder{
+				view:           tt.view,
+				req:            tt.req,
+				res:            tt.res,
+				activeBuilds:   tt.activeBuilds,
+				testCommitData: tt.testCommitData,
+			}
+			data, err := tb.buildTemplateData(context.Background())
+			if err != nil {
+				t.Fatal(err)
+			}
+			diff := cmp.Diff(tt.want, data, cmpopts.IgnoreUnexported(CommitInfo{}))
+			if diff != "" {
+				t.Errorf("mismatch want->got:\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestToBuildStatus(t *testing.T) {
+	tests := []struct {
+		name string
+		data *uiTemplateData
+		want types.BuildStatus
+	}{
+		{
+			name: "go repo",
+			data: &uiTemplateData{
+				Dashboard:  goDash,
+				Repo:       "go",
+				Package:    &Package{Name: "Go", Path: ""},
+				Branches:   []string{"release.foo", "release.bar", "dev.blah"},
+				Builders:   []string{"linux-386", "linux-amd64"},
+				Pagination: &Pagination{},
+				Commits: []*CommitInfo{
+					{
+						Hash:   "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						User:   "Foo Bar <foo@example.com>",
+						Desc:   "runtime: fix all the bugs",
+						Time:   time.Unix(1257894001, 0).UTC(),
+						Branch: "master",
+					},
+				},
+				TagState: []*TagState{
+					{
+						Name:     "master",
+						Tag:      &CommitInfo{Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac"},
+						Builders: []string{"linux-386", "linux-amd64"},
+						Packages: []*PackageState{
+							{
+								Package: &Package{Name: "net", Path: "golang.org/x/net"},
+								Commit: &CommitInfo{
+									Hash:        "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+									PackagePath: "golang.org/x/net",
+									User:        "Ee Yore <e@e.net>",
+									Desc:        "all: fix networking",
+									Time:        time.Unix(1257894001, 0).UTC(),
+									Branch:      "master",
+								},
+							},
+							{
+								Package: &Package{Name: "sys", Path: "golang.org/x/sys"},
+								Commit: &CommitInfo{
+									Hash:        "dddddddddddddddddddddddddddddddddddddddd",
+									PackagePath: "golang.org/x/sys",
+									User:        "Sys Tem <sys@s.net>",
+									Desc:        "sys: support more systems",
+									Time:        time.Unix(1257894001, 0).UTC(),
+									Branch:      "master",
+								},
+							},
+						},
+					},
+					{
+						Name:     "release-branch.go1.99",
+						Tag:      &CommitInfo{Hash: "ffffffffffffffffffffffffffffffffffffffff"},
+						Builders: []string{"linux-386", "linux-amd64"},
+						Packages: []*PackageState{
+							{
+								Package: &Package{Name: "net", Path: "golang.org/x/net"},
+								Commit: &CommitInfo{
+									Hash:        "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+									PackagePath: "golang.org/x/net",
+									User:        "Ee Yore <e@e.net>",
+									Desc:        "all: fix networking",
+									Time:        time.Unix(1257894001, 0).UTC(),
+									Branch:      "master",
+								},
+							},
+							{
+								Package: &Package{Name: "sys", Path: "golang.org/x/sys"},
+								Commit: &CommitInfo{
+									Hash:        "dddddddddddddddddddddddddddddddddddddddd",
+									PackagePath: "golang.org/x/sys",
+									User:        "Sys Tem <sys@s.net>",
+									Desc:        "sys: support more systems",
+									Time:        time.Unix(1257894001, 0).UTC(),
+									Branch:      "master",
+								},
+							},
+						},
+					},
+				},
+			},
+			want: types.BuildStatus{
+				Builders: []string{"linux-386", "linux-amd64"},
+				Revisions: []types.BuildRevision{
+					{
+						Repo:     "go",
+						Revision: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						Date:     "2009-11-10T23:00:01Z",
+						Branch:   "master",
+						Author:   "Foo Bar <foo@example.com>",
+						Desc:     "runtime: fix all the bugs",
+						Results:  []string{"", ""},
+					},
+					{
+						Repo:       "net",
+						Revision:   "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+						GoRevision: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						Date:       "2009-11-10T23:00:01Z",
+						Branch:     "master",
+						GoBranch:   "master",
+						Author:     "Ee Yore <e@e.net>",
+						Desc:       "all: fix networking",
+						Results:    []string{"", ""},
+					},
+					{
+						Repo:       "sys",
+						Revision:   "dddddddddddddddddddddddddddddddddddddddd",
+						GoRevision: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						Date:       "2009-11-10T23:00:01Z",
+						Branch:     "master",
+						GoBranch:   "master",
+						Author:     "Sys Tem <sys@s.net>",
+						Desc:       "sys: support more systems",
+						Results:    []string{"", ""},
+					},
+					{
+						Repo:       "net",
+						Revision:   "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+						GoRevision: "ffffffffffffffffffffffffffffffffffffffff",
+						Date:       "2009-11-10T23:00:01Z",
+						Branch:     "master",
+						GoBranch:   "release-branch.go1.99",
+						Author:     "Ee Yore <e@e.net>",
+						Desc:       "all: fix networking",
+						Results:    []string{"", ""},
+					},
+					{
+						Repo:       "sys",
+						Revision:   "dddddddddddddddddddddddddddddddddddddddd",
+						GoRevision: "ffffffffffffffffffffffffffffffffffffffff",
+						Date:       "2009-11-10T23:00:01Z",
+						Branch:     "master",
+						GoBranch:   "release-branch.go1.99",
+						Author:     "Sys Tem <sys@s.net>",
+						Desc:       "sys: support more systems",
+						Results:    []string{"", ""},
+					},
+				},
+			},
+		},
+		{
+			name: "other repo",
+			data: &uiTemplateData{
+				Dashboard: goDash,
+				Repo:      "tools",
+				Builders:  []string{"linux", "windows"},
+				Commits: []*CommitInfo{
+					{
+						PackagePath: "golang.org/x/tools",
+						Hash:        "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						User:        "Foo Bar <foo@example.com>",
+						Desc:        "tools: fix all the bugs",
+						Time:        time.Unix(1257894001, 0).UTC(),
+						Branch:      "master",
+						ResultData: []string{
+							"linux|false|123|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+							"windows|false|456|bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+						},
+					},
+				},
+			},
+			want: types.BuildStatus{
+				Builders: []string{"linux", "windows"},
+				Revisions: []types.BuildRevision{
+					{
+						Repo:       "tools",
+						Revision:   "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						GoRevision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+						Date:       "2009-11-10T23:00:01Z",
+						Branch:     "master",
+						Author:     "Foo Bar <foo@example.com>",
+						Desc:       "tools: fix all the bugs",
+						Results:    []string{"", "https://build.golang.org/log/456"},
+					},
+					{
+						Repo:       "tools",
+						Revision:   "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+						GoRevision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+						Date:       "2009-11-10T23:00:01Z",
+						Branch:     "master",
+						Author:     "Foo Bar <foo@example.com>",
+						Desc:       "tools: fix all the bugs",
+						Results:    []string{"https://build.golang.org/log/123", ""},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := toBuildStatus("build.golang.org", tt.data)
+			if diff := cmp.Diff(tt.want, got); diff != "" {
+				t.Errorf("buildStatus(...) mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}