[milo] Introduce build.html based off of build.proto

This adds a build.html that takes in a "BuildPage" struct,
which wraps a buildbucket build.proto struct.

This implementation is incomplete, but is accessible behind a hidden ?v2=1 flag.

This also breaks Timeline, which previously depended on MiloBuild,
but now depends on build.proto.

A sample live build was captured from today as testdata.

Bug: 850113
Change-Id: I417a19a65d4a1f7c2a942d648a12b57f07c4bde4
Reviewed-on: https://chromium-review.googlesource.com/c/1375309
Commit-Queue: Ryan Tseng <hinoka@chromium.org>
Reviewed-by: Nodir Turakulov <nodir@chromium.org>
diff --git a/milo/Makefile b/milo/Makefile
index 4767907..61be9bc 100644
--- a/milo/Makefile
+++ b/milo/Makefile
@@ -6,7 +6,7 @@
 projdir := $(patsubst %/,%,$(dir $(mkfile_path)))
 
 dev:
-	gae.py devserver --app-dir $(projdir)/frontend/appengine -- --host 0.0.0.0 --port 8082 --admin_port 7999 --log_level debug
+	gae.py devserver --app-dir $(projdir)/frontend/appengine -A luci-milo-dev -- --host 0.0.0.0 --port 8082 --admin_port 7999 --log_level debug
 
 # This is intentionally not dependent on the others below to avoid
 # asking for user confirmation multiple times.
diff --git a/milo/buildsource/buildbucket/build.go b/milo/buildsource/buildbucket/build.go
index 49b55cd..7a36116 100644
--- a/milo/buildsource/buildbucket/build.go
+++ b/milo/buildsource/buildbucket/build.go
@@ -16,108 +16,46 @@
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
-	"net/url"
+	"net/http"
 	"strconv"
-	"strings"
 
 	"github.com/golang/protobuf/ptypes"
 
+	"google.golang.org/genproto/protobuf/field_mask"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
 
 	"go.chromium.org/gae/service/datastore"
-	"go.chromium.org/luci/auth/identity"
-	"go.chromium.org/luci/buildbucket"
 	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
-	bbv1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1"
-	"go.chromium.org/luci/common/data/strpair"
 	"go.chromium.org/luci/common/errors"
 	"go.chromium.org/luci/common/logging"
 	gitpb "go.chromium.org/luci/common/proto/git"
-	miloProto "go.chromium.org/luci/common/proto/milo"
-	logDogTypes "go.chromium.org/luci/logdog/common/types"
+	"go.chromium.org/luci/grpc/prpc"
 	"go.chromium.org/luci/server/auth"
 
-	"go.chromium.org/luci/milo/buildsource/rawpresentation"
-	"go.chromium.org/luci/milo/buildsource/swarming"
 	"go.chromium.org/luci/milo/common"
 	"go.chromium.org/luci/milo/common/model"
 	"go.chromium.org/luci/milo/frontend/ui"
-	"go.chromium.org/luci/milo/git"
 )
 
 var ErrNotFound = errors.Reason("Build not found").Tag(common.CodeNotFound).Err()
 
-// GetSwarmingTaskID returns the swarming task ID of a buildbucket build.
-// TODO(hinoka): BuildInfo and Skia requires this.
-// Remove this when buildbucket v2 is out and Skia is on Kitchen.
-// TODO(nodir): delete this. It is used only in deprecated BuildInfo API.
-func GetSwarmingTaskID(c context.Context, buildAddress string) (host, taskId string, err error) {
-	host, err = getHost(c)
-	if err != nil {
-		return
+// BuildAddress constructs the build address of a buildbucketpb.Build.
+// This is used as the key for the BuildSummary entity.
+func BuildAddress(build *buildbucketpb.Build) string {
+	if build == nil {
+		return ""
 	}
-
-	bs := &model.BuildSummary{BuildKey: MakeBuildKey(c, host, buildAddress)}
-	switch err = datastore.Get(c, bs); err {
-	case nil:
-		for _, ctx := range bs.ContextURI {
-			u, err := url.Parse(ctx)
-			if err != nil {
-				continue
-			}
-			if u.Scheme == "swarming" && len(u.Path) > 1 {
-				toks := strings.Split(u.Path[1:], "/")
-				if toks[0] == "task" {
-					return u.Host, toks[1], nil
-				}
-			}
-		}
-		// continue to the fallback code below.
-
-	case datastore.ErrNoSuchEntity:
-		// continue to the fallback code below.
-
-	default:
-		return
+	num := strconv.FormatInt(build.Id, 10)
+	if build.Number != 0 {
+		num = strconv.FormatInt(int64(build.Number), 10)
 	}
-
-	// DEPRECATED(2017-12-01) {{
-	// This makes an RPC to buildbucket to obtain the swarming task ID.
-	// Now that we include this data in the BuildSummary.ContextUI we should never
-	// need to do this extra RPC. However, we have this codepath in place for old
-	// builds.
-	//
-	// After the deprecation date, this code can be removed; the only effect will
-	// be that buildbucket builds before 2017-11-03 will not render.
-	client, err := newBuildbucketClient(c, host)
-	if err != nil {
-		return
-	}
-	build, err := buildbucket.GetByAddress(c, client, buildAddress)
-	switch {
-	case err != nil:
-		err = errors.Annotate(err, "could not get build at %q", buildAddress).Err()
-		return
-	case build == nil:
-		err = errors.Reason("build at %q not found", buildAddress).Tag(common.CodeNotFound).Err()
-		return
-	}
-
-	host = build.Tags.Get("swarming_hostname")
-	taskId = build.Tags.Get("swarming_task_id")
-	if host == "" || taskId == "" {
-		err = errors.New("not a valid LUCI build")
-	}
-	return
-	// }}
-
+	b := build.Builder
+	return fmt.Sprintf("luci.%s.%s/%s/%s", b.Project, b.Bucket, b.Builder, num)
 }
 
-// simplisticBlamelist returns the ui.MiloBuildLegacy.Blame field from the
-// commit/gitiles buildset (if any).
+// simplisticBlamelist returns a slice of ui.Blame for a build.
 //
 // HACK(iannucci) - Getting the frontend to render a proper blamelist will
 // require some significant refactoring. To do this properly, we'll need:
@@ -191,154 +129,6 @@
 	return res
 }
 
-// extractDetails extracts the following from a build message's ResultDetailsJson:
-// * Build Info Text
-// * Swarming Bot ID
-// TODO(hinoka, nodir): Delete after buildbucket v2.
-func extractDetails(msg *bbv1.ApiCommonBuildMessage) (info string, botID string, err error) {
-	var resultDetails struct {
-		// TODO(nodir,iannucci): define a proto for build UI data
-		UI struct {
-			Info string `json:"info"`
-		} `json:"ui"`
-		Swarming struct {
-			TaskResult struct {
-				BotID string `json:"bot_id"`
-			} `json:"task_result"`
-			BotDimensions struct {
-				ID []string `json:"id"`
-			} `json:"bot_dimensions"`
-		} `json:"swarming"`
-	}
-	if msg.ResultDetailsJson != "" {
-		err = json.NewDecoder(strings.NewReader(msg.ResultDetailsJson)).Decode(&resultDetails)
-		info = resultDetails.UI.Info
-		botID = resultDetails.Swarming.TaskResult.BotID
-		if botID == "" && len(resultDetails.Swarming.BotDimensions.ID) > 0 {
-			botID = resultDetails.Swarming.BotDimensions.ID[0]
-		}
-	}
-	return
-}
-
-// toMiloBuildInMemory converts a buildbucket build to a milo build in memory.
-// Does not make RPCs.
-// In case of an error, returns a build with a description of the error
-// and logs the error.
-func toMiloBuildInMemory(c context.Context, msg *bbv1.ApiCommonBuildMessage) (*ui.MiloBuildLegacy, error) {
-	// Parse the build message into a buildbucket.Build struct, filling in the
-	// input and output properties that we expect to receive.
-	var b buildbucket.Build
-	var props struct {
-		Revision string `json:"revision"`
-	}
-	var resultDetails struct {
-		GotRevision string `json:"got_revision"`
-	}
-	b.Input.Properties = &props
-	b.Output.Properties = &resultDetails
-	if err := b.ParseMessage(msg); err != nil {
-		return nil, err
-	}
-	// TODO(hinoka,nodir): Replace the following lines after buildbucket v2.
-	info, botID, err := extractDetails(msg)
-	if err != nil {
-		return nil, err
-	}
-
-	// Now that all the data is parsed, put them all in the correct places.
-	pendingEnd := b.StartTime
-	if pendingEnd.IsZero() {
-		// Maybe the build expired and never started.  Use the expiration time, if any.
-		pendingEnd = b.CompletionTime
-	}
-	result := &ui.MiloBuildLegacy{
-		Summary: ui.BuildComponent{
-			PendingTime:   ui.NewInterval(c, b.CreationTime, pendingEnd),
-			ExecutionTime: ui.NewInterval(c, b.StartTime, b.CompletionTime),
-			Status:        parseStatus(b.Status),
-		},
-		Trigger: &ui.Trigger{},
-	}
-	// Add in revision information, if available.
-	if resultDetails.GotRevision != "" {
-		result.Trigger.Commit.Revision = ui.NewEmptyLink(resultDetails.GotRevision)
-	}
-	if props.Revision != "" {
-		result.Trigger.Commit.RequestRevision = ui.NewEmptyLink(props.Revision)
-	}
-
-	if b.Experimental {
-		result.Summary.Text = []string{"Experimental"}
-	}
-	if info != "" {
-		result.Summary.Text = append(result.Summary.Text, strings.Split(info, "\n")...)
-	}
-	if botID != "" {
-		result.Summary.Bot = ui.NewLink(
-			botID,
-			fmt.Sprintf(
-				"https://%s/bot?id=%s", b.Tags.Get("swarming_hostname"), botID),
-			fmt.Sprintf("Swarming Bot %s", botID))
-	}
-
-	for _, bs := range b.BuildSets {
-		// ignore rietveld.
-		cl, ok := bs.(*buildbucketpb.GerritChange)
-		if !ok {
-			continue
-		}
-
-		// support only one CL per build.
-		link := ui.NewPatchLink(cl)
-		result.Blame = []*ui.Commit{{
-			Changelist:      link,
-			RequestRevision: ui.NewLink(props.Revision, "", fmt.Sprintf("request revision %s", props.Revision)),
-		}}
-		result.Trigger.Changelist = link
-
-		result.Blame[0].AuthorEmail, err = git.Get(c).CLEmail(c, cl.Host, cl.Change)
-		switch {
-		case err == context.DeadlineExceeded:
-			result.Blame[0].AuthorEmail = "<Gerrit took too long respond>"
-			fallthrough
-		case err != nil:
-			logging.WithError(err).Errorf(c, "failed to load CL author for build %d", b.ID)
-		}
-		break
-	}
-
-	// Sprinkle in some extra information from Swarming
-	swarmingTags := strpair.ParseMap(b.Tags["swarming_tag"])
-	swarming.AddBanner(result, swarmingTags)
-	swarming.AddRecipeLink(result, swarmingTags)
-	swarming.AddProjectInfo(result, swarmingTags)
-	task := b.Tags.Get("swarming_task_id")
-	result.Summary.Source = ui.NewLink(
-		"Task "+task,
-		swarming.TaskPageURL(b.Tags.Get("swarming_hostname"), task).String(),
-		"Swarming task page for task "+task)
-
-	result.Summary.ParentLabel = ui.NewLink(
-		b.Builder,
-		fmt.Sprintf("/p/%s/builders/%s/%s", b.Project, b.Bucket, b.Builder),
-		fmt.Sprintf("builder %s", b.Builder))
-	if b.Number != nil {
-		numStr := strconv.Itoa(*b.Number)
-		result.Summary.Label = ui.NewLink(
-			numStr,
-			fmt.Sprintf("/p/%s/builders/%s/%s/%s", b.Project, b.Bucket, b.Builder, numStr),
-			fmt.Sprintf("build #%s", numStr))
-	} else {
-		idStr := strconv.FormatInt(b.ID, 10)
-		result.Summary.Label = ui.NewLink(
-			idStr,
-			fmt.Sprintf("/b/%s", idStr),
-			fmt.Sprintf("build #%s", idStr))
-	}
-	return result, nil
-}
-
 // GetBuildSummary fetches a build summary where the Context URI matches the
 // given address.
 func GetBuildSummary(c context.Context, id int64) (*model.BuildSummary, error) {
@@ -356,118 +146,80 @@
 	}
 }
 
-// GetRawBuild fetches a buildbucket build given its address.
-func GetRawBuild(c context.Context, address string) (*bbv1.ApiCommonBuildMessage, error) {
+// getBlame fetches blame information from Gitiles.
+// This requires the BuildSummary to be indexed in Milo.
+func getBlame(c context.Context, host string, b *buildbucketpb.Build) ([]*ui.Commit, error) {
+	commit := b.GetInput().GetGitilesCommit()
+	// No commit? No blamelist.
+	if commit == nil {
+		return nil, nil
+	}
+	// TODO(hinoka): This converts a buildbucketpb.Commit into a string
+	// and back into a buildbucketpb.Commit.  That's a bit silly.
+	return simplisticBlamelist(c, &model.BuildSummary{
+		BuildKey:  MakeBuildKey(c, host, BuildAddress(b)),
+		BuildSet:  []string{commit.BuildSetString()},
+		BuilderID: BuilderID{BuilderID: *b.Builder}.String(),
+	})
+}
+
+func buildbucketClient(c context.Context, host string) (buildbucketpb.BuildsClient, error) {
+	t, err := auth.GetRPCTransport(c, auth.AsUser)
+	if err != nil {
+		return nil, err
+	}
+	return buildbucketpb.NewBuildsPRPCClient(&prpc.Client{
+		C:    &http.Client{Transport: t},
+		Host: host,
+	}), nil
+}
+
+// GetBuild returns the buildbucketpb.Build from a build request.
+func GetBuild(c context.Context, host string, bid buildbucketpb.GetBuildRequest) (*buildbucketpb.Build, error) {
+	client, err := buildbucketClient(c, host)
+	if err != nil {
+		return nil, err
+	}
+	return client.GetBuild(c, &bid)
+}
+
+var fullBuildMask = &field_mask.FieldMask{
+	// TODO(hinoka): Add statusReason here.
+	Paths: []string{
+		"id",
+		"builder",
+		"number",
+		"created_by",
+		"create_time",
+		"start_time",
+		"end_time",
+		"update_time",
+		"status",
+		"input",
+		"output",
+		"steps",
+		"infra",
+	},
+}
+
+// GetBuildPage fetches the full set of information for a Milo build page from Buildbucket.
+// Including the blamelist and other auxiliary information.
+func GetBuildPage(c context.Context, br buildbucketpb.GetBuildRequest) (*ui.BuildPage, error) {
+	br.Fields = fullBuildMask
 	host, err := getHost(c)
 	if err != nil {
 		return nil, err
 	}
-
-	client, err := newBuildbucketClient(c, host)
+	b, err := GetBuild(c, host, br)
 	if err != nil {
 		return nil, err
 	}
-	// This runs a search RPC against BuildBucket, but it is optimized for speed.
-	build, err := bbv1.GetByAddress(c, client, address)
-	switch {
-	case err != nil:
-		return nil, errors.Annotate(err, "could not get build at %q", address).Err()
-	case build == nil && auth.CurrentUser(c).Identity == identity.AnonymousIdentity:
-		return nil, errors.Reason("not logged in").Tag(common.CodeUnauthorized).Err()
-	case build == nil:
-		return nil, errors.Reason("build at %q not found", address).Tag(common.CodeNotFound).Err()
-	default:
-		return build, nil
-	}
-}
-
-// getStep fetches returns the Step annoations from LogDog.
-func getStep(c context.Context, bbBuildMessage *bbv1.ApiCommonBuildMessage) (*logDogTypes.StreamAddr, *miloProto.Step, error) {
-	swarmingTags := strpair.ParseMap(bbBuildMessage.Tags)["swarming_tag"]
-	logLocation := strpair.ParseMap(swarmingTags).Get("log_location")
-	if logLocation == "" {
-		return nil, nil, errors.New("Build is missing log_location")
-	}
-	addr, err := logDogTypes.ParseURL(logLocation)
-	if err != nil {
-		return nil, nil, errors.Annotate(err, "%s is invalid", addr).Err()
-	}
-
-	step, err := rawpresentation.ReadAnnotations(c, addr)
-	return addr, step, err
-}
-
-// getBlame fetches blame information from Gitiles.  This requires the
-// BuildSummary to be indexed in Milo.
-func getBlame(c context.Context, msg *bbv1.ApiCommonBuildMessage) ([]*ui.Commit, error) {
-	host, err := getHost(c)
+	blame, err := getBlame(c, host, b)
 	if err != nil {
 		return nil, err
 	}
-	tags := strpair.ParseMap(msg.Tags)
-	bSet, _ := tags["buildset"]
-	bid := NewBuilderID(msg.Bucket, tags.Get("builder"))
-	bs := &model.BuildSummary{
-		BuildKey:  MakeBuildKey(c, host, tags.Get("build_address")),
-		BuildSet:  bSet,
-		BuilderID: bid.String(),
-	}
-	return simplisticBlamelist(c, bs)
-}
-
-// GetBuild is a shortcut for GetRawBuild and ToMiloBuild.
-func GetBuild(c context.Context, address string, fetchFull bool) (*ui.MiloBuildLegacy, error) {
-	bbBuildMessage, err := GetRawBuild(c, address)
-	if err != nil {
-		return nil, err
-	}
-	return ToMiloBuild(c, bbBuildMessage, fetchFull)
-}
-
-// ToMiloBuild converts a raw buildbucket build to a milo build.
-//
-// Returns an error only on failure to reach buildbucket.
-// Other errors are surfaced in the returned build.
-//
-// TODO(hinoka): Some of this can be done concurrently. Investigate if this call
-// takes >500ms on average.
-// TODO(crbug.com/850113): stop loading steps from logdog.
-func ToMiloBuild(c context.Context, b *bbv1.ApiCommonBuildMessage, fetchFull bool) (*ui.MiloBuildLegacy, error) {
-	mb, err := toMiloBuildInMemory(c, b)
-	if err != nil {
-		return nil, err
-	}
-
-	if !fetchFull {
-		return mb, nil
-	}
-
-	// Add step information from LogDog.  If this fails, we still have perfectly
-	// valid information from Buildbucket, so just annotate the build with the
-	// error and continue.
-	if b.StartedTs != 0 {
-		if addr, step, err := getStep(c, b); err == nil {
-			ub := rawpresentation.NewURLBuilder(addr)
-			mb.Components, mb.PropertyGroup = rawpresentation.SubStepsToUI(c, ub, step.Substep)
-		} else if b.Status == bbv1.StatusCompleted {
-			// TODO(hinoka): This might be better placed in a error butterbar.
-			mb.Components = append(mb.Components, &ui.BuildComponent{
-				Label:  ui.NewEmptyLink("Failed to fetch step information from LogDog"),
-				Text:   strings.Split(err.Error(), "\n"),
-				Status: model.InfraFailure,
-			})
-		}
-	}
-
-	// Add blame information.  If this fails, just add in a placeholder with an error.
-	if blame, err := getBlame(c, b); err == nil {
-		mb.Blame = blame
-	} else {
-		logging.WithError(err).Warningf(c, "failed to fetch blame information")
-		mb.Blame = []*ui.Commit{{
-			Description: fmt.Sprintf("Failed to fetch blame information\n%s", err.Error()),
-		}}
-	}
-
-	return mb, nil
+	return &ui.BuildPage{
+		Build: *b,
+		Blame: blame,
+	}, nil
 }
diff --git a/milo/buildsource/buildbucket/build_legacy.go b/milo/buildsource/buildbucket/build_legacy.go
new file mode 100644
index 0000000..333c10a
--- /dev/null
+++ b/milo/buildsource/buildbucket/build_legacy.go
@@ -0,0 +1,373 @@
+// Copyright 2018 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package buildbucket
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/url"
+	"strconv"
+	"strings"
+
+	"go.chromium.org/gae/service/datastore"
+	"go.chromium.org/luci/auth/identity"
+	"go.chromium.org/luci/buildbucket"
+	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
+	bbv1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1"
+	"go.chromium.org/luci/common/data/strpair"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	miloProto "go.chromium.org/luci/common/proto/milo"
+	logDogTypes "go.chromium.org/luci/logdog/common/types"
+	"go.chromium.org/luci/server/auth"
+
+	"go.chromium.org/luci/milo/buildsource/rawpresentation"
+	"go.chromium.org/luci/milo/buildsource/swarming"
+	"go.chromium.org/luci/milo/common"
+	"go.chromium.org/luci/milo/common/model"
+	"go.chromium.org/luci/milo/frontend/ui"
+	"go.chromium.org/luci/milo/git"
+)
+
+// GetSwarmingTaskID returns the swarming task ID of a buildbucket build.
+// TODO(hinoka): BuildInfo and Skia requires this.
+// Remove this when buildbucket v2 is out and Skia is on Kitchen.
+// TODO(nodir): delete this. It is used only in deprecated BuildInfo API.
+func GetSwarmingTaskID(c context.Context, buildAddress string) (host, taskId string, err error) {
+	host, err = getHost(c)
+	if err != nil {
+		return
+	}
+
+	bs := &model.BuildSummary{BuildKey: MakeBuildKey(c, host, buildAddress)}
+	switch err = datastore.Get(c, bs); err {
+	case nil:
+		for _, ctx := range bs.ContextURI {
+			u, err := url.Parse(ctx)
+			if err != nil {
+				continue
+			}
+			if u.Scheme == "swarming" && len(u.Path) > 1 {
+				toks := strings.Split(u.Path[1:], "/")
+				if toks[0] == "task" {
+					return u.Host, toks[1], nil
+				}
+			}
+		}
+		// continue to the fallback code below.
+
+	case datastore.ErrNoSuchEntity:
+		// continue to the fallback code below.
+
+	default:
+		return
+	}
+
+	// DEPRECATED(2017-12-01) {{
+	// This makes an RPC to buildbucket to obtain the swarming task ID.
+	// Now that we include this data in the BuildSummary.ContextUI we should never
+	// need to do this extra RPC. However, we have this codepath in place for old
+	// builds.
+	//
+	// After the deprecation date, this code can be removed; the only effect will
+	// be that buildbucket builds before 2017-11-03 will not render.
+	client, err := newBuildbucketClient(c, host)
+	if err != nil {
+		return
+	}
+	build, err := buildbucket.GetByAddress(c, client, buildAddress)
+	switch {
+	case err != nil:
+		err = errors.Annotate(err, "could not get build at %q", buildAddress).Err()
+		return
+	case build == nil:
+		err = errors.Reason("build at %q not found", buildAddress).Tag(common.CodeNotFound).Err()
+		return
+	}
+
+	host = build.Tags.Get("swarming_hostname")
+	taskId = build.Tags.Get("swarming_task_id")
+	if host == "" || taskId == "" {
+		err = errors.New("not a valid LUCI build")
+	}
+	return
+	// }}
+
+}
+
+// extractDetails extracts the following from a build message's ResultDetailsJson:
+// * Build Info Text
+// * Swarming Bot ID
+// TODO(hinoka, nodir): Delete after buildbucket v2.
+func extractDetails(msg *bbv1.ApiCommonBuildMessage) (info string, botID string, err error) {
+	var resultDetails struct {
+		// TODO(nodir,iannucci): define a proto for build UI data
+		UI struct {
+			Info string `json:"info"`
+		} `json:"ui"`
+		Swarming struct {
+			TaskResult struct {
+				BotID string `json:"bot_id"`
+			} `json:"task_result"`
+			BotDimensions struct {
+				ID []string `json:"id"`
+			} `json:"bot_dimensions"`
+		} `json:"swarming"`
+	}
+	if msg.ResultDetailsJson != "" {
+		err = json.NewDecoder(strings.NewReader(msg.ResultDetailsJson)).Decode(&resultDetails)
+		info = resultDetails.UI.Info
+		botID = resultDetails.Swarming.TaskResult.BotID
+		if botID == "" && len(resultDetails.Swarming.BotDimensions.ID) > 0 {
+			botID = resultDetails.Swarming.BotDimensions.ID[0]
+		}
+	}
+	return
+}
+
+// toMiloBuildInMemory converts a buildbucket build to a milo build in memory.
+// Does not make RPCs.
+// In case of an error, returns a build with a description of the error
+// and logs the error.
+func toMiloBuildInMemory(c context.Context, msg *bbv1.ApiCommonBuildMessage) (*ui.MiloBuildLegacy, error) {
+	// Parse the build message into a buildbucket.Build struct, filling in the
+	// input and output properties that we expect to receive.
+	var b buildbucket.Build
+	var props struct {
+		Revision string `json:"revision"`
+	}
+	var resultDetails struct {
+		GotRevision string `json:"got_revision"`
+	}
+	b.Input.Properties = &props
+	b.Output.Properties = &resultDetails
+	if err := b.ParseMessage(msg); err != nil {
+		return nil, err
+	}
+	// TODO(hinoka,nodir): Replace the following lines after buildbucket v2.
+	info, botID, err := extractDetails(msg)
+	if err != nil {
+		return nil, err
+	}
+
+	// Now that all the data is parsed, put them all in the correct places.
+	pendingEnd := b.StartTime
+	if pendingEnd.IsZero() {
+		// Maybe the build expired and never started.  Use the expiration time, if any.
+		pendingEnd = b.CompletionTime
+	}
+	result := &ui.MiloBuildLegacy{
+		Summary: ui.BuildComponent{
+			PendingTime:   ui.NewInterval(c, b.CreationTime, pendingEnd),
+			ExecutionTime: ui.NewInterval(c, b.StartTime, b.CompletionTime),
+			Status:        parseStatus(b.Status),
+		},
+		Trigger: &ui.Trigger{},
+	}
+	// Add in revision information, if available.
+	if resultDetails.GotRevision != "" {
+		result.Trigger.Commit.Revision = ui.NewEmptyLink(resultDetails.GotRevision)
+	}
+	if props.Revision != "" {
+		result.Trigger.Commit.RequestRevision = ui.NewEmptyLink(props.Revision)
+	}
+
+	if b.Experimental {
+		result.Summary.Text = []string{"Experimental"}
+	}
+	if info != "" {
+		result.Summary.Text = append(result.Summary.Text, strings.Split(info, "\n")...)
+	}
+	if botID != "" {
+		result.Summary.Bot = ui.NewLink(
+			botID,
+			fmt.Sprintf(
+				"https://%s/bot?id=%s", b.Tags.Get("swarming_hostname"), botID),
+			fmt.Sprintf("Swarming Bot %s", botID))
+	}
+
+	for _, bs := range b.BuildSets {
+		// ignore rietveld.
+		cl, ok := bs.(*buildbucketpb.GerritChange)
+		if !ok {
+			continue
+		}
+
+		// support only one CL per build.
+		link := ui.NewPatchLink(cl)
+		result.Blame = []*ui.Commit{{
+			Changelist:      link,
+			RequestRevision: ui.NewLink(props.Revision, "", fmt.Sprintf("request revision %s", props.Revision)),
+		}}
+		result.Trigger.Changelist = link
+
+		result.Blame[0].AuthorEmail, err = git.Get(c).CLEmail(c, cl.Host, cl.Change)
+		switch {
+		case err == context.DeadlineExceeded:
+			result.Blame[0].AuthorEmail = "<Gerrit took too long respond>"
+			fallthrough
+		case err != nil:
+			logging.WithError(err).Errorf(c, "failed to load CL author for build %d", b.ID)
+		}
+		break
+	}
+
+	// Sprinkle in some extra information from Swarming
+	swarmingTags := strpair.ParseMap(b.Tags["swarming_tag"])
+	swarming.AddBanner(result, swarmingTags)
+	swarming.AddRecipeLink(result, swarmingTags)
+	swarming.AddProjectInfo(result, swarmingTags)
+	task := b.Tags.Get("swarming_task_id")
+	result.Summary.Source = ui.NewLink(
+		"Task "+task,
+		swarming.TaskPageURL(b.Tags.Get("swarming_hostname"), task).String(),
+		"Swarming task page for task "+task)
+
+	result.Summary.ParentLabel = ui.NewLink(
+		b.Builder,
+		fmt.Sprintf("/p/%s/builders/%s/%s", b.Project, b.Bucket, b.Builder),
+		fmt.Sprintf("builder %s", b.Builder))
+	if b.Number != nil {
+		numStr := strconv.Itoa(*b.Number)
+		result.Summary.Label = ui.NewLink(
+			numStr,
+			fmt.Sprintf("/p/%s/builders/%s/%s/%s", b.Project, b.Bucket, b.Builder, numStr),
+			fmt.Sprintf("build #%s", numStr))
+	} else {
+		idStr := strconv.FormatInt(b.ID, 10)
+		result.Summary.Label = ui.NewLink(
+			idStr,
+			fmt.Sprintf("/b/%s", idStr),
+			fmt.Sprintf("build #%s", idStr))
+	}
+	return result, nil
+}
+
+// GetRawBuild fetches a buildbucket build given its address.
+func GetRawBuild(c context.Context, address string) (*bbv1.ApiCommonBuildMessage, error) {
+	host, err := getHost(c)
+	if err != nil {
+		return nil, err
+	}
+
+	client, err := newBuildbucketClient(c, host)
+	if err != nil {
+		return nil, err
+	}
+	// This runs a search RPC against BuildBucket, but it is optimized for speed.
+	build, err := bbv1.GetByAddress(c, client, address)
+	switch {
+	case err != nil:
+		return nil, errors.Annotate(err, "could not get build at %q", address).Err()
+	case build == nil && auth.CurrentUser(c).Identity == identity.AnonymousIdentity:
+		return nil, errors.Reason("not logged in").Tag(common.CodeUnauthorized).Err()
+	case build == nil:
+		return nil, errors.Reason("build at %q not found", address).Tag(common.CodeNotFound).Err()
+	default:
+		return build, nil
+	}
+}
+
+// getStep fetches returns the Step annoations from LogDog.
+func getStep(c context.Context, bbBuildMessage *bbv1.ApiCommonBuildMessage) (*logDogTypes.StreamAddr, *miloProto.Step, error) {
+	swarmingTags := strpair.ParseMap(bbBuildMessage.Tags)["swarming_tag"]
+	logLocation := strpair.ParseMap(swarmingTags).Get("log_location")
+	if logLocation == "" {
+		return nil, nil, errors.New("Build is missing log_location")
+	}
+	addr, err := logDogTypes.ParseURL(logLocation)
+	if err != nil {
+		return nil, nil, errors.Annotate(err, "%s is invalid", addr).Err()
+	}
+
+	step, err := rawpresentation.ReadAnnotations(c, addr)
+	return addr, step, err
+}
+
+// GetBuild is a shortcut for GetRawBuild and ToMiloBuild.
+func GetBuildLegacy(c context.Context, address string, fetchFull bool) (*ui.MiloBuildLegacy, error) {
+	bbBuildMessage, err := GetRawBuild(c, address)
+	if err != nil {
+		return nil, err
+	}
+	return ToMiloBuild(c, bbBuildMessage, fetchFull)
+}
+
+// ToMiloBuild converts a raw buildbucket build to a milo build.
+//
+// Returns an error only on failure to reach buildbucket.
+// Other errors are surfaced in the returned build.
+//
+// TODO(hinoka): Some of this can be done concurrently. Investigate if this call
+// takes >500ms on average.
+// TODO(crbug.com/850113): stop loading steps from logdog.
+func ToMiloBuild(c context.Context, b *bbv1.ApiCommonBuildMessage, fetchFull bool) (*ui.MiloBuildLegacy, error) {
+	mb, err := toMiloBuildInMemory(c, b)
+	if err != nil {
+		return nil, err
+	}
+
+	if !fetchFull {
+		return mb, nil
+	}
+
+	// Add step information from LogDog.  If this fails, we still have perfectly
+	// valid information from Buildbucket, so just annotate the build with the
+	// error and continue.
+	if b.StartedTs != 0 {
+		if addr, step, err := getStep(c, b); err == nil {
+			ub := rawpresentation.NewURLBuilder(addr)
+			mb.Components, mb.PropertyGroup = rawpresentation.SubStepsToUI(c, ub, step.Substep)
+		} else if b.Status == bbv1.StatusCompleted {
+			// TODO(hinoka): This might be better placed in a error butterbar.
+			mb.Components = append(mb.Components, &ui.BuildComponent{
+				Label:  ui.NewEmptyLink("Failed to fetch step information from LogDog"),
+				Text:   strings.Split(err.Error(), "\n"),
+				Status: model.InfraFailure,
+			})
+		}
+	}
+
+	// Add blame information.  If this fails, just add in a placeholder with an error.
+	if blame, err := getBlameLegacy(c, b); err == nil {
+		mb.Blame = blame
+	} else {
+		logging.WithError(err).Warningf(c, "failed to fetch blame information")
+		mb.Blame = []*ui.Commit{{
+			Description: fmt.Sprintf("Failed to fetch blame information\n%s", err.Error()),
+		}}
+	}
+
+	return mb, nil
+}
+
+// getBlameLegacy fetches blame information from Gitiles.  This requires the
+// BuildSummary to be indexed in Milo.
+func getBlameLegacy(c context.Context, msg *bbv1.ApiCommonBuildMessage) ([]*ui.Commit, error) {
+	host, err := getHost(c)
+	if err != nil {
+		return nil, err
+	}
+	tags := strpair.ParseMap(msg.Tags)
+	bSet, _ := tags["buildset"]
+	bid := NewBuilderID(msg.Bucket, tags.Get("builder"))
+	bs := &model.BuildSummary{
+		BuildKey:  MakeBuildKey(c, host, tags.Get("build_address")),
+		BuildSet:  bSet,
+		BuilderID: bid.String(),
+	}
+	return simplisticBlamelist(c, bs)
+}
diff --git a/milo/buildsource/buildbucket/html_data.go b/milo/buildsource/buildbucket/html_data.go
new file mode 100644
index 0000000..1380e09
--- /dev/null
+++ b/milo/buildsource/buildbucket/html_data.go
@@ -0,0 +1,44 @@
+// Copyright 2018 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package buildbucket
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/golang/protobuf/jsonpb"
+
+	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
+)
+
+// TestCases are the list of known mock data.
+// We put this here instead of _test.go to allow for debug data in dev instances.
+// TODO(hinoka): Implement debug view for localhost development.
+var TestCases = []string{"linux-rel"}
+
+// GetTestBuild returns a debug build from testdata.
+func GetTestBuild(c context.Context, relDir, name string) (*buildbucketpb.Build, error) {
+	fname := fmt.Sprintf("%s.build.jsonpb", name)
+	path := filepath.Join(relDir, "testdata", fname)
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	result := &buildbucketpb.Build{}
+	return result, jsonpb.Unmarshal(f, result)
+}
diff --git a/milo/buildsource/buildbucket/testdata/linux-rel.build.jsonpb b/milo/buildsource/buildbucket/testdata/linux-rel.build.jsonpb
new file mode 100644
index 0000000..7c0f8d5
--- /dev/null
+++ b/milo/buildsource/buildbucket/testdata/linux-rel.build.jsonpb
@@ -0,0 +1,654 @@
+{
+  "status": "SUCCESS",
+  "updateTime": "2018-12-14T01:12:56.696949Z",
+  "createdBy": "user:luci-scheduler@appspot.gserviceaccount.com",
+  "builder": {
+    "project": "chromium",
+    "builder": "linux-rel",
+    "bucket": "ci"
+  },
+  "number": 13052,
+  "id": "8927206632544641856",
+  "steps": [
+    {
+      "status": "SUCCESS",
+      "name": "setup_build",
+      "startTime": "2018-12-14T01:02:28.569713448Z",
+      "endTime": "2018-12-14T01:02:28.881827136Z",
+      "summaryMarkdown": "running recipe: \"chromium\"",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/setup_build/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fsetup_build%2F0%2Fstdout",
+          "name": "stdout"
+        },
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/setup_build/0/logs/run_recipe/0",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fsetup_build%2F0%2Flogs%2Frun_recipe%2F0",
+          "name": "run_recipe"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "name": "report builders",
+      "startTime": "2018-12-14T01:02:28.891218198Z",
+      "endTime": "2018-12-14T01:02:29.111869148Z",
+      "summaryMarkdown": "<br/>running builder/tester 'linux-rel' on master 'chromium'",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/report_builders/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Freport_builders%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "name": "bot_update",
+      "startTime": "2018-12-14T01:02:29.112575899Z",
+      "endTime": "2018-12-14T01:03:15.368454580Z",
+      "summaryMarkdown": "[71GB/295GB used (24%)]",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/bot_update/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fbot_update%2F0%2Fstdout",
+          "name": "stdout"
+        },
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/bot_update/0/logs/json.output/0",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fbot_update%2F0%2Flogs%2Fjson.output%2F0",
+          "name": "json.output"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:03:15.443521865Z",
+      "name": "ensure_goma",
+      "startTime": "2018-12-14T01:03:15.368741106Z"
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:03:15.443019010Z",
+      "name": "ensure_goma|ensure_goma.ensure_installed",
+      "startTime": "2018-12-14T01:03:15.368741106Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/ensure_goma/0/steps/ensure_installed/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fensure_goma%2F0%2Fsteps%2Fensure_installed%2F0%2Fstdout",
+          "name": "stdout"
+        },
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/ensure_goma/0/steps/ensure_installed/0/logs/json.output/0",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fensure_goma%2F0%2Fsteps%2Fensure_installed%2F0%2Flogs%2Fjson.output%2F0",
+          "name": "json.output"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "name": "swarming.py --version",
+      "startTime": "2018-12-14T01:03:15.443774683Z",
+      "endTime": "2018-12-14T01:03:16.021406989Z",
+      "summaryMarkdown": "0.14",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/swarming.py_--version/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fswarming.py_--version%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:03:22.117647337Z",
+      "name": "clobber",
+      "startTime": "2018-12-14T01:03:16.021876874Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/clobber/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fclobber%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:03:31.802421722Z",
+      "name": "gclient runhooks",
+      "startTime": "2018-12-14T01:03:22.118140610Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/gclient_runhooks/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fgclient_runhooks%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:03:34.367273846Z",
+      "name": "get compile targets for scripts",
+      "startTime": "2018-12-14T01:03:31.803243546Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/get_compile_targets_for_scripts/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fget_compile_targets_for_scripts%2F0%2Fstdout",
+          "name": "stdout"
+        },
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/get_compile_targets_for_scripts/0/logs/json.output/0",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fget_compile_targets_for_scripts%2F0%2Flogs%2Fjson.output%2F0",
+          "name": "json.output"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "name": "read test spec (chromium.json)",
+      "startTime": "2018-12-14T01:03:34.367719339Z",
+      "endTime": "2018-12-14T01:03:34.606023133Z",
+      "summaryMarkdown": "path: /b/swarming/w/ir/cache/builder/src/testing/buildbot/chromium.json",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/read_test_spec__chromium.json_/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fread_test_spec__chromium.json_%2F0%2Fstdout",
+          "name": "stdout"
+        },
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/read_test_spec__chromium.json_/0/logs/json.output/0",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fread_test_spec__chromium.json_%2F0%2Flogs%2Fjson.output%2F0",
+          "name": "json.output"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "name": "lookup GN args",
+      "startTime": "2018-12-14T01:03:34.606490267Z",
+      "endTime": "2018-12-14T01:03:35.068038111Z",
+      "summaryMarkdown": "<br/>is_component_build = false<br/>is_debug = false<br/>strip_absolute_paths_from_debug_symbols = true<br/>use_goma = true<br/>goma_dir = \"/b/swarming/w/ir/cache/goma/client\"",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/lookup_GN_args/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Flookup_GN_args%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:03:45.204139631Z",
+      "name": "generate_build_files",
+      "startTime": "2018-12-14T01:03:35.068635173Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/generate_build_files/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fgenerate_build_files%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:04:00.225491722Z",
+      "name": "preprocess_for_goma",
+      "startTime": "2018-12-14T01:03:45.205092856Z"
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:03:45.639284153Z",
+      "name": "preprocess_for_goma|preprocess_for_goma.goma cache directory",
+      "startTime": "2018-12-14T01:03:45.205092856Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/preprocess_for_goma/0/steps/goma_cache_directory/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fpreprocess_for_goma%2F0%2Fsteps%2Fgoma_cache_directory%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "name": "preprocess_for_goma|preprocess_for_goma.start_goma",
+      "startTime": "2018-12-14T01:03:45.639841172Z",
+      "endTime": "2018-12-14T01:03:58.709298971Z",
+      "summaryMarkdown": "* [cloudtail](https://console.cloud.google.com/logs/viewer?project=goma-logs&resource=gce_instance%2Finstance_id%2Fswarm582-c4&timestamp=2018-12-14T01:03:58.708124)",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/preprocess_for_goma/0/steps/start_goma/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fpreprocess_for_goma%2F0%2Fsteps%2Fstart_goma%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:04:00.224933097Z",
+      "name": "preprocess_for_goma|preprocess_for_goma.start cloudtail",
+      "startTime": "2018-12-14T01:03:58.709901181Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/preprocess_for_goma/0/steps/start_cloudtail/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fpreprocess_for_goma%2F0%2Fsteps%2Fstart_cloudtail%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:11:29.138847280Z",
+      "name": "compile",
+      "startTime": "2018-12-14T01:04:00.225956894Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/compile/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fcompile%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:11:35.101027734Z",
+      "name": "compile confirm no-op",
+      "startTime": "2018-12-14T01:11:29.139499295Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/compile_confirm_no-op/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fcompile_confirm_no-op%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:12:14.402833277Z",
+      "name": "postprocess_for_goma",
+      "startTime": "2018-12-14T01:11:35.102269436Z"
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:11:35.603433061Z",
+      "name": "postprocess_for_goma|postprocess_for_goma.goma_jsonstatus",
+      "startTime": "2018-12-14T01:11:35.102269436Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/postprocess_for_goma/0/steps/goma_jsonstatus/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fpostprocess_for_goma%2F0%2Fsteps%2Fgoma_jsonstatus%2F0%2Fstdout",
+          "name": "stdout"
+        },
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/postprocess_for_goma/0/steps/goma_jsonstatus/0/logs/json.output/0",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fpostprocess_for_goma%2F0%2Fsteps%2Fgoma_jsonstatus%2F0%2Flogs%2Fjson.output%2F0",
+          "name": "json.output"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:11:36.105229592Z",
+      "name": "postprocess_for_goma|postprocess_for_goma.goma_stat",
+      "startTime": "2018-12-14T01:11:35.603852976Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/postprocess_for_goma/0/steps/goma_stat/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fpostprocess_for_goma%2F0%2Fsteps%2Fgoma_stat%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:11:36.638390650Z",
+      "name": "postprocess_for_goma|postprocess_for_goma.stop_goma",
+      "startTime": "2018-12-14T01:11:36.105773641Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/postprocess_for_goma/0/steps/stop_goma/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fpostprocess_for_goma%2F0%2Fsteps%2Fstop_goma%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "name": "postprocess_for_goma|postprocess_for_goma.upload_log",
+      "startTime": "2018-12-14T01:11:36.638890476Z",
+      "endTime": "2018-12-14T01:12:12.869796283Z",
+      "summaryMarkdown": "* [compiler_proxy_log](https://chromium-build-stats.appspot.com/compiler_proxy_log/2018/12/14/swarm582-c4/compiler_proxy.swarm582-c4.chrome-bot.log.INFO.20181213-170346.21326.gz)\n* [ninja_log](https://chromium-build-stats.appspot.com/ninja_log/2018/12/14/swarm582-c4/ninja_log.swarm582-c4.chrome-bot.20181213-171129.15062.gz)",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/postprocess_for_goma/0/steps/upload_log/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fpostprocess_for_goma%2F0%2Fsteps%2Fupload_log%2F0%2Fstdout",
+          "name": "stdout"
+        },
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/postprocess_for_goma/0/steps/upload_log/0/logs/json.output/0",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fpostprocess_for_goma%2F0%2Fsteps%2Fupload_log%2F0%2Flogs%2Fjson.output%2F0",
+          "name": "json.output"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:12:14.402247573Z",
+      "name": "postprocess_for_goma|postprocess_for_goma.stop cloudtail",
+      "startTime": "2018-12-14T01:12:12.870238683Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/postprocess_for_goma/0/steps/stop_cloudtail/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fpostprocess_for_goma%2F0%2Fsteps%2Fstop_cloudtail%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:12:43.357041741Z",
+      "name": "archive_build",
+      "startTime": "2018-12-14T01:12:14.403383068Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/archive_build/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Farchive_build%2F0%2Fstdout",
+          "name": "stdout"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:12:43.358325497Z",
+      "name": "test_pre_run",
+      "startTime": "2018-12-14T01:12:43.358206727Z"
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:12:46.888353529Z",
+      "name": "sizes",
+      "startTime": "2018-12-14T01:12:43.359391022Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/sizes/0/stdout",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fsizes%2F0%2Fstdout",
+          "name": "stdout"
+        },
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/sizes/0/logs/json.output/0",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Fsizes%2F0%2Flogs%2Fjson.output%2F0",
+          "name": "json.output"
+        }
+      ]
+    },
+    {
+      "status": "SUCCESS",
+      "endTime": "2018-12-14T01:12:46.889300996Z",
+      "name": "recipe result",
+      "startTime": "2018-12-14T01:12:46.888929802Z",
+      "logs": [
+        {
+          "url": "logdog://logs.chromium.org/chromium/buildbucket/cr-buildbucket.appspot.com/8927206632544641856/+/steps/recipe_result/0/logs/result/0",
+          "viewUrl": "https://logs.chromium.org/v/?s=chromium%2Fbuildbucket%2Fcr-buildbucket.appspot.com%2F8927206632544641856%2F%2B%2Fsteps%2Frecipe_result%2F0%2Flogs%2Fresult%2F0",
+          "name": "result"
+        }
+      ]
+    }
+  ],
+  "startTime": "2018-12-14T01:02:21.404334Z",
+  "output": {
+    "properties": {
+      "got_nacl_revision": "14b228f23bb875bd4a25d3e88bd5f15d1bc8c87a",
+      "got_swarming_client_revision": "0e3e1c4dc4e79f25a5b58fcbc135dc93183c0c54",
+      "got_revision": "62c93f6041326a2c4781680583e433bbfad93884",
+      "recipe": "chromium",
+      "got_dawn_revision": "ff9562f7927b614f16449f3c757c57c70543eb37",
+      "got_webrtc_revision_cp": "refs/heads/master@{#25992}",
+      "$recipe_engine/path": {
+        "cache_dir": "/b/swarming/w/ir/cache",
+        "temp_dir": "/b/swarming/w/ir/tmp/rt"
+      },
+      "got_revision_cp": "refs/heads/master@{#616534}",
+      "branch": "refs/heads/master",
+      "revision": "62c93f6041326a2c4781680583e433bbfad93884",
+      "repository": "https://chromium.googlesource.com/chromium/src.git",
+      "buildername": "linux-rel",
+      "got_webrtc_revision": "f7f13e0742a999c991ea3d2871dd99a042736c28",
+      "mastername": "chromium",
+      "got_angle_revision": "91002266bd1e1aff9d0c8aeb288480be3da188e0",
+      "buildbucket": {
+        "hostname": "cr-buildbucket.appspot.com",
+        "build": {
+          "created_ts": 1544749339498335,
+          "tags": [
+            "builder:linux-rel",
+            "buildset:commit/git/62c93f6041326a2c4781680583e433bbfad93884",
+            "buildset:commit/gitiles/chromium.googlesource.com/chromium/src/+/62c93f6041326a2c4781680583e433bbfad93884",
+            "gitiles_ref:refs/heads/master",
+            "scheduler_invocation_id:9092636694632938592",
+            "scheduler_job_id:chromium/linux-rel",
+            "user_agent:luci-scheduler"
+          ],
+          "bucket": "luci.chromium.ci",
+          "created_by": "user:luci-scheduler@appspot.gserviceaccount.com",
+          "project": "chromium",
+          "id": "8927206632544641856"
+        }
+      },
+      "got_v8_revision": "d373bc28c00ac547aa61a7b4e025d728724eb476",
+      "got_v8_revision_cp": "refs/heads/7.3.132@{#1}",
+      "$recipe_engine/runtime": {
+        "is_experimental": false,
+        "is_luci": true
+      },
+      "buildnumber": 13052,
+      "path_config": "generic",
+      "bot_id": "swarm582-c4",
+      "got_buildtools_revision": "5cce74c6ae2e0a24751e92b3ed3f92f8e76935ec"
+    }
+  },
+  "input": {
+    "properties": {
+      "$recipe_engine/runtime": {
+        "is_experimental": false,
+        "is_luci": true
+      },
+      "repository": "https://chromium.googlesource.com/chromium/src.git",
+      "buildername": "linux-rel",
+      "mastername": "chromium",
+      "buildnumber": 13052.0,
+      "buildbucket": {
+        "hostname": "cr-buildbucket.appspot.com",
+        "build": {
+          "created_ts": 1544749339498335.0,
+          "tags": [
+            "builder:linux-rel",
+            "buildset:commit/git/62c93f6041326a2c4781680583e433bbfad93884",
+            "buildset:commit/gitiles/chromium.googlesource.com/chromium/src/+/62c93f6041326a2c4781680583e433bbfad93884",
+            "gitiles_ref:refs/heads/master",
+            "scheduler_invocation_id:9092636694632938592",
+            "scheduler_job_id:chromium/linux-rel",
+            "user_agent:luci-scheduler"
+          ],
+          "bucket": "luci.chromium.ci",
+          "created_by": "user:luci-scheduler@appspot.gserviceaccount.com",
+          "project": "chromium",
+          "id": "8927206632544641856"
+        }
+      },
+      "$kitchen": {
+        "devshell": true,
+        "git_auth": true
+      },
+      "branch": "refs/heads/master",
+      "revision": "62c93f6041326a2c4781680583e433bbfad93884"
+    },
+    "gitilesCommit": {
+      "project": "chromium/src",
+      "host": "chromium.googlesource.com",
+      "id": "62c93f6041326a2c4781680583e433bbfad93884"
+    }
+  },
+  "endTime": "2018-12-14T01:12:56.253875Z",
+  "createTime": "2018-12-14T01:02:19.498335Z",
+  "infra": {
+    "buildbucket": {
+      "serviceConfigRevision": "daaff082e95b94bec84fa6e440a8b97677d6f76d"
+    },
+    "recipe": {
+      "name": "chromium",
+      "cipdPackage": "infra/recipe_bundles/chromium.googlesource.com/chromium/tools/build"
+    },
+    "swarming": {
+      "taskServiceAccount": "chromium-ci-builder@chops-service-accounts.iam.gserviceaccount.com",
+      "hostname": "chromium-swarm.appspot.com",
+      "priority": 30,
+      "botDimensions": [
+        {
+          "value": "linux-archive-rel",
+          "key": "builder"
+        },
+        {
+          "value": "linux-rel",
+          "key": "builder"
+        },
+        {
+          "value": "builder_3864ec50ed02f46932ac4406ad4722a4ac567f1f2f5d155253b8a9746ffa0321_v2",
+          "key": "caches"
+        },
+        {
+          "value": "git",
+          "key": "caches"
+        },
+        {
+          "value": "goma_v2",
+          "key": "caches"
+        },
+        {
+          "value": "vpython",
+          "key": "caches"
+        },
+        {
+          "value": "32",
+          "key": "cores"
+        },
+        {
+          "value": "x86",
+          "key": "cpu"
+        },
+        {
+          "value": "x86-64",
+          "key": "cpu"
+        },
+        {
+          "value": "x86-64-Haswell_GCE",
+          "key": "cpu"
+        },
+        {
+          "value": "x86-64-avx2",
+          "key": "cpu"
+        },
+        {
+          "value": "1",
+          "key": "gce"
+        },
+        {
+          "value": "none",
+          "key": "gpu"
+        },
+        {
+          "value": "swarm582-c4",
+          "key": "id"
+        },
+        {
+          "value": "chrome-trusty-18042300-b7223b463e3",
+          "key": "image"
+        },
+        {
+          "value": "0",
+          "key": "inside_docker"
+        },
+        {
+          "value": "1",
+          "key": "kvm"
+        },
+        {
+          "value": "n1-standard-32",
+          "key": "machine_type"
+        },
+        {
+          "value": "Linux",
+          "key": "os"
+        },
+        {
+          "value": "Ubuntu",
+          "key": "os"
+        },
+        {
+          "value": "Ubuntu-14.04",
+          "key": "os"
+        },
+        {
+          "value": "luci.chromium.ci",
+          "key": "pool"
+        },
+        {
+          "value": "2.7.6",
+          "key": "python"
+        },
+        {
+          "value": "3937-62425bd",
+          "key": "server_version"
+        },
+        {
+          "value": "us",
+          "key": "zone"
+        },
+        {
+          "value": "us-central",
+          "key": "zone"
+        },
+        {
+          "value": "us-central1",
+          "key": "zone"
+        },
+        {
+          "value": "us-central1-b",
+          "key": "zone"
+        }
+      ],
+      "taskId": "41c30ddc76b2e910",
+      "taskDimensions": [
+        {
+          "value": "linux-rel",
+          "key": "builder"
+        },
+        {
+          "value": "builder_3864ec50ed02f46932ac4406ad4722a4ac567f1f2f5d155253b8a9746ffa0321_v2",
+          "key": "caches"
+        },
+        {
+          "value": "32",
+          "key": "cores"
+        },
+        {
+          "value": "x86-64",
+          "key": "cpu"
+        },
+        {
+          "value": "Ubuntu-14.04",
+          "key": "os"
+        },
+        {
+          "value": "luci.chromium.ci",
+          "key": "pool"
+        }
+      ]
+    },
+    "logdog": {
+      "project": "chromium",
+      "prefix": "buildbucket/cr-buildbucket.appspot.com/8927206632544641856",
+      "hostname": "logs.chromium.org"
+    }
+  }
+}
diff --git a/milo/frontend/appengine/static/buildbot/css/default.css b/milo/frontend/appengine/static/buildbot/css/default.css
index 76da5db..bc09350 100644
--- a/milo/frontend/appengine/static/buildbot/css/default.css
+++ b/milo/frontend/appengine/static/buildbot/css/default.css
@@ -445,13 +445,13 @@
 }
 
 /* LastBuild, BuildStep states */
-.success, .status-Success, .status-Idle, .tree-status-open {
+.success, .status-Success, .status-Idle, .tree-status-open, .status.SUCCESS {
   color: #000;
   background-color: #8d4;
   border-color: #4F8530;
 }
 
-.failure, .status-Failure, .status-Cancelled, .tree-status-closed {
+.failure, .status-Failure, .status-Cancelled, .tree-status-closed, .status.FAILURE, .status.CANCELLED {
   color: #000;
   background-color: #e88;
   border-color: #A77272;
@@ -465,7 +465,7 @@
   border-style: solid;
 }
 
-.status-InfraFailure {
+.status-InfraFailure, .status.INFRA_FAILURE {
   color: #FFFFFF;
   background-color: #c6c;
   border-color: #ACA0B3;
@@ -495,13 +495,13 @@
   border-color: #ACA0B3;
 }
 
-.start, .status-NotRun {
+.start, .status-NotRun, .status.SCHEDULED {
   color: #000;
   background-color: #ccc;
   border-color: #ccc;
 }
 
-.running, .waiting, td.building, .status-Running, .status-Busy, .tree-status-throttled {
+.running, .waiting, td.building, .status-Running, .status-Busy, .tree-status-throttled, .status.STARTED {
   color: #000;
   background-color: #fd3;
   border-color: #C5C56D;
diff --git a/milo/frontend/appengine/static/common/js/build.js b/milo/frontend/appengine/static/common/js/build.js
index e48965e..31597e5 100644
--- a/milo/frontend/appengine/static/common/js/build.js
+++ b/milo/frontend/appengine/static/common/js/build.js
@@ -102,14 +102,14 @@
     return timeline;
   }
 
-  // Switches the view to a mode where results, properties, and changes go on
+  // Switches the view to a mode where overview, properties, and changes go on
   // one tab and the timeline goes on a second tab. This is intended to be
   // easier to read on a wide screen and waste less horizontal space.
   function goWideMode() {
-    // Add the colummn class back to the results, properties, and changes
-    // divs and move the properties and changes divs to the "Results" tab.
-    $('#results').addClass('column');
-    $('#results-tab')
+    // Add the colummn class back to the overview, properties, and changes
+    // divs and move the properties and changes divs to the "overview" tab.
+    $('#overview').addClass('column');
+    $('#overview-tab')
         .append($('#properties').addClass('column'))
         .append($('#changes').addClass('column'));
     // Remove the "Properties" and "Changes" tabs. Note that the corresponding
@@ -119,7 +119,7 @@
   }
 
   // Narrow mode is the default, switch if necessary.
-  if ($(window).width() > 1440) {
+  if ($(window).width() > 1440 && useTabs) {
     goWideMode();
   }
 
diff --git a/milo/frontend/appengine/templates/pages/build.html b/milo/frontend/appengine/templates/pages/build.html
new file mode 100644
index 0000000..c74e9f4
--- /dev/null
+++ b/milo/frontend/appengine/templates/pages/build.html
@@ -0,0 +1,287 @@
+{{define "title"}}
+  {{ with .BuildPage.Build -}}
+    {{ if eq .Status.String "INFRA_FAILURE" }}
+      Infra Failure
+    {{ else if eq .Status.String "FAILURE" }}
+      Failed
+    {{ else if eq .Status.String "SCHEDULED" }}
+      Pending
+    {{ else }}
+      {{ .Status.String }}
+    {{ end }}
+    -
+    {{ with .Builder }}{{ .Builder }}{{ end }}
+    {{ if .Number }}{{ .Number }}{{ else }}{{ .Id }}{{ end }}
+  {{- end }}
+{{end}}
+
+{{define "head"}}
+<link rel="stylesheet" href="/static/common/css/timeline.css" type="text/css">
+<link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
+<script>
+  const timelineData = JSON.parse({{ .BuildPage.Timeline }});
+  const useTabs = true;
+</script>
+<script src="/static/common/js/build.js"></script>
+{{end}}
+
+{{define "interval"}}
+  {{ if .Started }}
+    <span class="duration"
+          data-starttime="{{ .Start | formatTime }}"
+          {{ if .Ended -}}
+            data-endtime="{{ .End | formatTime }}"
+          {{- end }}>
+          ( {{ .Duration | humanDuration }} )</span>
+  {{ end }}
+{{end}}
+
+{{define "favicon"}}
+<link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/
+{{- with .BuildPage.Status.String -}}
+  {{- if eq . "STARTED" -}} yellow
+  {{- else if eq . "SUCCESS" -}} green
+  {{- else if eq . "INFRA_FAILURE" -}} purple
+  {{- else if eq . "FAILURE" -}} red
+  {{- else if eq . "CANCELLED" -}} brown
+  {{- else if eq . "SCHEDULED" -}} gray
+  {{- else -}} milo
+  {{- end -}}
+{{- end -}}-32.png">
+
+{{end}}
+
+{{define "step"}}
+<li class="{{ if eq .Step.Status.String "SUCCESS" }}green{{ end }}
+           {{- if .Children }} substeps
+             {{- if .Collapsed }} collapsed{{ end }}
+           {{- end }}">
+  <div class="status {{.Step.Status}} result">
+      {{ template "interval" toInterval .StartTime .EndTime }}
+    <b>{{.ShortName}}</b>
+    <span>
+      <div class="summary-markdown">{{ .Step.SummaryMarkdown | renderMarkdown }}</div>
+    </span>
+  </div>
+  {{ if .Children }}
+    <ol>
+    {{ range .Children  }}
+      {{ template "step" . }}
+    {{ end }}
+    </ol>
+  {{ end }}
+</li>
+{{ end }}
+
+{{define "body"}}
+  <div class="content">
+    <h1>
+    {{ with .BuildPage.Builder }}
+      Builder {{ . }}
+    {{ end }}
+    Build {{ .BuildPage.BuildID.HTML }}
+    {{ range .BuildPage.Banners }}
+      <img src="/static/common/logos/{{.LogoBase.Img}}" alt="{{.LogoBase.Alt}}"
+           width="25px">
+    {{ end }}
+    </h1>
+
+    <div id="tabs" style="display: none;">
+      <ul>
+        <li><a href="#overview-tab">Overview</a></li>
+        <li><a href="#properties-tab">Properties</a></li>
+        <li><a href="#changes-tab">Changes</a></li>
+        <li><a href="#timeline-tab">Timeline</a></li>
+      </ul>
+      <div id="overview-tab">{{ template "overview_tab" . }}</div>
+      <div id="properties-tab">{{ template "properties_tab" . }}</div>
+      <div id="changes-tab">{{ template "changes_tab" . }}</div>
+      <div id="timeline-tab">{{ template "timeline_tab" . }}</div>
+    </div>
+  </div>
+{{end}}
+
+{{define "overview_tab"}}
+  <div id="overview">
+    <!--- TODO(hinoka): Stylize this -->
+    {{ range .BuildPage.Errors }}
+      <p class="status FAILURE">Error while rendering page: {{.}}</p>
+    {{ end }}
+    <h2>Overview</h2>
+    <p class="result status {{.BuildPage.Status}}">
+      {{ .BuildPage.Output.SummaryMarkdown | renderMarkdown }}
+    </p>
+
+    {{ with .BuildPage.Input }}
+      <h2>Input</h2>
+      <table>
+        {{ with .GitilesCommit }}
+          <tr>
+            <td class="left">Revision</td>
+            <td><a href="https://{{ .Host }}/{{ .Project }}/+/{{ .Id }}">{{ .Id }}</a>
+            {{ with .Position }}(CP #{{ . }}){{ end }}
+            </td>
+          </tr>
+        {{ end }}
+
+        {{ range .GerritChanges }}
+          <tr>
+            <td class="left">Patch</td>
+            <td>
+              <a href="https://{{ .Host }}/c/{{ .Project }}/{{ .Change }}/{{ .Patchset }}">
+              {{ .Change }} (ps #{{ .Patchset }})
+              </a>
+            </td>
+          </tr>
+        {{ end }}
+      </table>
+    {{ end }}
+
+    <h2>Infra</h2>
+    <ul>
+      <li>Buildbucket ID: {{ .BuildPage.Id }}</li>
+
+      {{ with .BuildPage.Infra }}
+        {{ with .Swarming }}
+        <li>
+          Swarming Task:
+          <a href="https://{{ .Hostname }}/task?id={{ .TaskId }}&show_raw=1&wide_logs=true">
+            {{ .TaskId }}
+          </a>
+        </li>
+        <li>Bot: {{ . | botLink }}</li>
+        {{ end }}
+
+        {{ with .Recipe }}
+          <li>
+            Recipe: {{ . | recipeLink }}
+          </li>
+        {{ end }}
+      {{ end }}
+    </ul>
+
+    <h2>Steps and Logs</h2>
+    Show:
+    <input type="radio" name="hider" id="showExpanded"
+           {{- if eq .BuildPage.StepDisplayPref "expanded" }} checked{{ end }}>
+    <label for="showExpanded">Expanded</label>
+    <input type="radio" name="hider" id="showDefault"
+           {{- if eq .BuildPage.StepDisplayPref "default" }} checked{{ end }}>
+    <label for="showDefault">Default</label>
+    <input type="radio" name="hider" id="showNonGreen"
+           {{- if eq .BuildPage.StepDisplayPref "non-green" }} checked{{ end }}>
+    <label for="showNonGreen">Non-Green</label>
+
+    <ol id="steps" {{- if eq .BuildPage.StepDisplayPref "non-green" }} class="non-green"{{ end }}>
+      {{ range .Steps }}
+        {{ template "step" . }}
+      {{ end }}
+    </ol>
+
+    <h2>Timing</h2>
+    <table class="info" width="100%">
+      <tr class="alt">
+        <td class="left">Create</td>
+        <td>{{ .BuildPage.CreateTime | toTime | localTime "N/A" }}</td>
+      </tr>
+      <tr>
+        <td class="left">Start</td>
+        <td>{{ .BuildPage.StartTime | toTime | localTime "N/A" }}</td>
+      </tr>
+      <tr class="alt">
+        <td class="left">End</td>
+        <td>{{ .BuildPage.EndTime | toTime | localTime "N/A" }}</td>
+      </tr>
+      <tr>
+        <td class="left">Pending</td>
+        <td id="duration">{{ duration .BuildPage.CreateTime .BuildPage.StartTime }}</td>
+      </tr>
+      <tr class="alt">
+        <td class="left">Execution</td>
+        <td id="duration">{{ duration .BuildPage.StartTime .BuildPage.EndTime }}</td>
+      </tr>
+    </table>
+  </div>
+{{end}}
+
+{{define "properties_tab"}}
+  <div id="properties">
+    {{ with .Build.Input.Properties }}
+      <h2>Input Properties</h2>
+      {{ . | renderProperties }}
+    {{ end }}
+
+    {{ with .Build.Output.Properties }}
+      <h2>Output Properties</h2>
+      {{ . | renderProperties }}
+    {{ end }}
+
+  </div>
+{{end}}
+
+{{define "changes_tab"}}
+  <div id="changes">
+    <h2>All Changes</h2>
+    {{ if .Build.Blame }}
+    <ol>
+    {{ range .Build.Blame }}
+    <li>
+      <h3>{{.Title}}</h3>
+      <table class="info">
+        <tbody>
+          <tr>
+            <td class="left">Changed by</td>
+            <td class="value">
+                {{ if .AuthorName }}{{ .AuthorName }} - {{ end }}
+                {{ .AuthorEmail | obfuscateEmail }}
+            </td>
+          </tr>
+          <tr>
+            <td class="left">Changed at</td>
+            <td class="value">{{ .CommitTime | localTime "N/A" }}</td>
+          </tr>
+          <tr>
+            <td class="left">Repository</td>
+            <td class="value">{{ .Repo }}</td>
+          </tr>
+          <tr>
+            <td class="left">Branch</td>
+            <td class="value">{{ .Branch }}</td>
+          </tr>
+          {{ with .Revision }}
+            <tr>
+              <td class="left">Revision</td>
+              <td class="value">{{ .HTML }}</td>
+            </tr>
+          {{ end }}
+        </tbody>
+      </table>
+
+      {{ if .Description }}
+        <h3>Comments</h3>
+        <pre class="comments">{{ .Description | formatCommitDesc }}</pre>
+      {{ end }}
+
+      {{ if .File }}
+        <h3 class="files">Changed files</h3>
+        <ul class="alternating">
+          {{ range .File }}
+          <li class="file">{{ . }}</li>
+          {{ end }}
+        </ul>
+      {{ end }}
+
+    </li>
+    {{ end }} <!-- range .Build.Blame -->
+    </ol>
+    {{ else }}
+      No Blamelist
+    {{ end }} <!-- if .Build.Blame -->
+  </div>
+{{end}}
+
+{{define "timeline_tab"}}
+  <div id="timeline">
+    <div id="timeline-rendering">Rendering...</div>
+  </div>
+{{end}}
diff --git a/milo/frontend/appengine/templates/pages/build_legacy.html b/milo/frontend/appengine/templates/pages/build_legacy.html
index 21fb115..8e71d2f 100644
--- a/milo/frontend/appengine/templates/pages/build_legacy.html
+++ b/milo/frontend/appengine/templates/pages/build_legacy.html
@@ -17,14 +17,11 @@
 {{end}}
 
 {{define "head"}}
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
-{{if .TimelineJSON}}
-  <link rel="stylesheet" href="/static/common/css/timeline.css" type="text/css">
-  <script>
-    const timelineData = JSON.parse({{ .TimelineJSON }});
-  </script>
-{{end}}
 {{end}}
 
 {{define "interval"}}
@@ -125,25 +122,19 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        {{ if .TimelineJSON }}
-          <li><a href="#timeline-tab">Timeline</a></li>
-        {{ end }}
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">{{ template "results" . }}</div>
-      <div id="properties-tab">{{ template "properties" . }}</div>
-      <div id="changes-tab">{{ template "changes" . }}</div>
-      {{ if .TimelineJSON }}
-        <div id="timeline-tab">{{ template "timeline" . }}</div>
-      {{ end }}
+      <div id="overview-tab">
+        {{ template "overview" . }}
+        {{ template "properties" . }}
+        {{ template "changes" . }}
+      </div>
     </div>
   </div>
 {{end}}
 
-{{define "results"}}
-  <div id="results">
+{{define "overview"}}
+  <div id="results" class="column">
     <h2>Results:</h2>
     {{ with .Build.Summary }}
       <p class="result status-{{.Status}}">
@@ -273,7 +264,7 @@
 {{end}}
 
 {{define "properties"}}
-  <div id="properties">
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -336,7 +327,7 @@
 {{end}}
 
 {{define "changes"}}
-  <div id="changes">
+  <div id="changes" class="column">
     {{ if .Build.Blame }}
     <h2>All Changes:</h2>
     <ol>
@@ -393,9 +384,3 @@
     {{ end }}
   </div>
 {{end}}
-
-{{define "timeline"}}
-  <div id="timeline">
-    <div id="timeline-rendering">Rendering...</div>
-  </div>
-{{end}}
diff --git a/milo/frontend/expectations/buildbot.build-Debug_page-_CrWinGoma_30608.html b/milo/frontend/expectations/buildbot.build-Debug_page-_CrWinGoma_30608.html
index be7b1ae..8e34039 100644
--- a/milo/frontend/expectations/buildbot.build-Debug_page-_CrWinGoma_30608.html
+++ b/milo/frontend/expectations/buildbot.build-Debug_page-_CrWinGoma_30608.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/green-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -56,13 +58,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Success">
@@ -665,9 +665,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -918,9 +918,9 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
     <h2>All Changes:</h2>
     <ol>
@@ -1498,8 +1498,8 @@
     </ol>
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/buildbot.build-Debug_page-_chromium_presubmit_426944.html b/milo/frontend/expectations/buildbot.build-Debug_page-_chromium_presubmit_426944.html
index 7e96b9a..6e0b61e 100644
--- a/milo/frontend/expectations/buildbot.build-Debug_page-_chromium_presubmit_426944.html
+++ b/milo/frontend/expectations/buildbot.build-Debug_page-_chromium_presubmit_426944.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/green-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -62,13 +64,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Success">
@@ -430,9 +430,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -761,9 +761,9 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
     <h2>All Changes:</h2>
     <ol>
@@ -809,8 +809,8 @@
     </ol>
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/buildbot.build-Debug_page-_gerritCL_1234.html b/milo/frontend/expectations/buildbot.build-Debug_page-_gerritCL_1234.html
index 582be81..59825f4 100644
--- a/milo/frontend/expectations/buildbot.build-Debug_page-_gerritCL_1234.html
+++ b/milo/frontend/expectations/buildbot.build-Debug_page-_gerritCL_1234.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/red-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -56,13 +58,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Failure">
@@ -174,9 +174,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -237,13 +237,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/buildbot.build-Debug_page-_newline_1234.html b/milo/frontend/expectations/buildbot.build-Debug_page-_newline_1234.html
index d623115..53b0d54 100644
--- a/milo/frontend/expectations/buildbot.build-Debug_page-_newline_1234.html
+++ b/milo/frontend/expectations/buildbot.build-Debug_page-_newline_1234.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/red-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -56,13 +58,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Failure">
@@ -172,9 +172,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -214,13 +214,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/buildbot.build-Debug_page-_win_chromium_rel_ng_246309.html b/milo/frontend/expectations/buildbot.build-Debug_page-_win_chromium_rel_ng_246309.html
index 2772ccf..06eac74 100644
--- a/milo/frontend/expectations/buildbot.build-Debug_page-_win_chromium_rel_ng_246309.html
+++ b/milo/frontend/expectations/buildbot.build-Debug_page-_win_chromium_rel_ng_246309.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/red-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -56,13 +58,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Failure">
@@ -949,9 +949,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -1274,9 +1274,9 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
     <h2>All Changes:</h2>
     <ol>
@@ -1322,8 +1322,8 @@
     </ol>
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/buildbucket.build-Test_page-_linux-rel.html b/milo/frontend/expectations/buildbucket.build-Test_page-_linux-rel.html
new file mode 100644
index 0000000..1a4cadb
--- /dev/null
+++ b/milo/frontend/expectations/buildbucket.build-Test_page-_linux-rel.html
@@ -0,0 +1,253 @@
+
+<!DOCTYPE html>
+
+<html lang="en">
+<meta charset="utf-8">
+<meta name="google" value="notranslate">
+<title>
+  
+      SUCCESS
+    
+    -
+    linux-rel
+    13052
+</title>
+<link rel="stylesheet" href="/static/buildbot/css/default.css" type="text/css">
+<link rel="stylesheet" href="/static/common/third_party/css/jquery-ui.min.css" type="text/css">
+<link rel="stylesheet" href="/static/common/third_party/css/vis.min.css" type="text/css">
+<link rel="search" type="application/opensearchdescription+xml" href="/opensearch.xml" title="LUCI" />
+<script src="/static/common/third_party/js/moment-with-locales.min.js"></script>
+<script src="/static/common/third_party/js/moment-timezone-with-data-2012-2022.min.js"></script>
+<script src="/static/common/js/time.js"></script>
+<script src="/static/common/third_party/js/jquery.min.js"></script>
+<script src="/static/common/third_party/js/jquery-ui.min.js"></script>
+<script src="/static/common/third_party/js/vis-custom.min.js"></script>
+
+<link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/green-32.png">
+
+
+<link rel="stylesheet" href="/static/common/css/timeline.css" type="text/css">
+<link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
+<script>
+  const timelineData = JSON.parse("\"timeline goes here\"");
+  const useTabs = true;
+</script>
+<script src="/static/common/js/build.js"></script>
+
+
+<body class="interface">
+  <header>
+    <div>
+      <a href="/" aria-label="Home page">Home</a> |
+      <a href="/search" aria-label="Search page">Search</a>
+    </div>
+    <div>
+      
+        <a href="http://fake.example.com/login?dest=%2Ffoobar" alt="Login">Login</a>
+      
+    </div>
+  </header>
+  <hr>
+  
+  <div class="content">
+    <h1>
+    
+      Builder linux-rel
+    
+    Build <a href="/p/chromium/builder/ci/linux-rel/13052" aria-label="Build 13052">13052</a>
+    
+    </h1>
+
+    <div id="tabs" style="display: none;">
+      <ul>
+        <li><a href="#overview-tab">Overview</a></li>
+        <li><a href="#properties-tab">Properties</a></li>
+        <li><a href="#changes-tab">Changes</a></li>
+        <li><a href="#timeline-tab">Timeline</a></li>
+      </ul>
+      <div id="overview-tab">
+  <div id="overview">
+    
+    
+    <h2>Overview</h2>
+    <p class="result status SUCCESS">
+      <pre></pre>
+    </p>
+
+    
+      <h2>Input</h2>
+      <table>
+        
+          <tr>
+            <td class="left">Revision</td>
+            <td><a href="https://chromium.googlesource.com/chromium/src/+/62c93f6041326a2c4781680583e433bbfad93884">62c93f6041326a2c4781680583e433bbfad93884</a>
+            
+            </td>
+          </tr>
+        
+
+        
+      </table>
+    
+
+    <h2>Infra</h2>
+    <ul>
+      <li>Buildbucket ID: 8927206632544641856</li>
+
+      
+        
+        <li>
+          Swarming Task:
+          <a href="https://chromium-swarm.appspot.com/task?id=41c30ddc76b2e910&show_raw=1&wide_logs=true">
+            41c30ddc76b2e910
+          </a>
+        </li>
+        <li>Bot: <a href="https://chromium-swarm.appspot.com/bot?id=swarm582-c4" aria-label="swarming bot swarm582-c4">swarm582-c4</a></li>
+        
+
+        
+          <li>
+            Recipe: <a href="https://cs.chromium.org/search/?q=file:recipes/chromium.py" aria-label="recipe chromium">chromium</a>
+          </li>
+        
+      
+    </ul>
+
+    <h2>Steps and Logs</h2>
+    Show:
+    <input type="radio" name="hider" id="showExpanded">
+    <label for="showExpanded">Expanded</label>
+    <input type="radio" name="hider" id="showDefault">
+    <label for="showDefault">Default</label>
+    <input type="radio" name="hider" id="showNonGreen">
+    <label for="showNonGreen">Non-Green</label>
+
+    <ol id="steps">
+      
+    </ol>
+
+    <h2>Timing</h2>
+    <table class="info" width="100%">
+      <tr class="alt">
+        <td class="left">Create</td>
+        <td><span class="local-time " data-timestamp="1544749339498">Friday, 14-Dec-18 01:02:19 UTC</span></td>
+      </tr>
+      <tr>
+        <td class="left">Start</td>
+        <td><span class="local-time " data-timestamp="1544749341404">Friday, 14-Dec-18 01:02:21 UTC</span></td>
+      </tr>
+      <tr class="alt">
+        <td class="left">End</td>
+        <td><span class="local-time " data-timestamp="1544749976253">Friday, 14-Dec-18 01:12:56 UTC</span></td>
+      </tr>
+      <tr>
+        <td class="left">Pending</td>
+        <td id="duration">1 secs</td>
+      </tr>
+      <tr class="alt">
+        <td class="left">Execution</td>
+        <td id="duration">10 mins 34 secs</td>
+      </tr>
+    </table>
+  </div>
+</div>
+      <div id="properties-tab">
+  <div id="properties">
+    
+
+    
+
+  </div>
+</div>
+      <div id="changes-tab">
+  <div id="changes">
+    <h2>All Changes</h2>
+    
+      No Blamelist
+     
+  </div>
+</div>
+      <div id="timeline-tab">
+  <div id="timeline">
+    <div id="timeline-rendering">Rendering...</div>
+  </div>
+</div>
+    </div>
+  </div>
+
+  <footer>
+    <hr>
+      <div><img class="lucy-logo" src="https://storage.googleapis.com/chrome-infra/lucy-small.png"></div>
+      <div>
+        <a href="https://chromium.googlesource.com/infra/luci">LUCI</a><br>
+        built: <b><span class="local-time " data-timestamp="-6792498672871">Saturday, 03-Feb-01 04:05:06 UTC</span></b><br>
+        version: <b>testVersionID</b><br>
+      </div>
+  </footer>
+</body>
+<script>
+$(function () {
+  'use strict';
+  
+  setTimeout(function() {
+    milo.makeTimesLocal();
+    milo.annotateDurations();
+  }, 0);
+  $(document).tooltip({
+    show: false,
+    hide: false
+  });
+});
+</script>
+<script>
+  (function(i,s,o,g,r,a,m){i['CrDXObject']=r;i[r]=i[r]||function(){
+    (i[r].q=i[r].q||[]).push(arguments)},a=s.createElement(o),
+    m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
+    })(window,document,'script','https://storage.googleapis.com/crdx-feedback.appspot.com/feedback.js','crdx');
+
+  crdx('setFeedbackButtonLink', 'https://bugs.chromium.org/p/chromium/issues/entry?components=Infra%3EPlatform%3EMilo');
+
+(function(window) {
+  let lastWindowScrollTop = window.scrollY;
+
+  function debounce(f, wait) {
+    let timeout;
+    return function(...args) {
+      if (timeout) {
+        clearTimeout(timeout);
+      }
+      timeout = setTimeout(() => {
+        f(...args);
+        timeout = null;
+      }, wait);
+    };
+  }
+
+  window.addEventListener('scroll', debounce(function(evt) {
+    const delta = window.scrollY - lastWindowScrollTop;
+    lastWindowScrollTop = window.scrollY;
+    const absDelta = Math.abs(delta);
+    const category = window.location.pathname.split('/').slice(0, 2).join('/');
+    ga('send', 'event', category, 'scroll-abs', '', absDelta);
+    if (delta > 0) {
+      ga('send', 'event', category, 'scroll-down', '', absDelta);
+    } else {
+      ga('send', 'event', category, 'scroll-up', '', absDelta);
+    }
+  }, 250));
+})(window);
+</script>
+
+<script>
+setTimeout(function() {
+	(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
+	(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
+	m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
+	})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
+
+	ga('create', 'UA-12345-01', 'auto');
+	ga('send', 'pageview');
+}, 0);
+</script>
+
+</html>
diff --git a/milo/frontend/expectations/swarming.build-Basic_successful_build.html b/milo/frontend/expectations/swarming.build-Basic_successful_build.html
index c8c9e81..36f9c89 100644
--- a/milo/frontend/expectations/swarming.build-Basic_successful_build.html
+++ b/milo/frontend/expectations/swarming.build-Basic_successful_build.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/green-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Success">
@@ -79,9 +79,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -121,13 +121,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-canceled.html b/milo/frontend/expectations/swarming.build-build-canceled.html
index a646721..5a0557e 100644
--- a/milo/frontend/expectations/swarming.build-build-canceled.html
+++ b/milo/frontend/expectations/swarming.build-build-canceled.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/red-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -62,13 +64,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Failure">
@@ -230,9 +230,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -314,13 +314,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-exception.html b/milo/frontend/expectations/swarming.build-build-exception.html
index b0b4b8a..6baba35 100644
--- a/milo/frontend/expectations/swarming.build-build-exception.html
+++ b/milo/frontend/expectations/swarming.build-build-exception.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/red-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Failure">
@@ -274,9 +274,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -364,13 +364,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-expired.html b/milo/frontend/expectations/swarming.build-build-expired.html
index 1268bf2..6895912 100644
--- a/milo/frontend/expectations/swarming.build-build-expired.html
+++ b/milo/frontend/expectations/swarming.build-build-expired.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/darkpurple-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -62,13 +64,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Expired">
@@ -131,9 +131,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -176,13 +176,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-finished-logdog-expired-stream.html b/milo/frontend/expectations/swarming.build-build-finished-logdog-expired-stream.html
index d15850c..24aae88 100644
--- a/milo/frontend/expectations/swarming.build-build-finished-logdog-expired-stream.html
+++ b/milo/frontend/expectations/swarming.build-build-finished-logdog-expired-stream.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/purple-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-InfraFailure">
@@ -165,9 +165,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -216,13 +216,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-gerrit.html b/milo/frontend/expectations/swarming.build-build-gerrit.html
index 317b404..a1962ed 100644
--- a/milo/frontend/expectations/swarming.build-build-gerrit.html
+++ b/milo/frontend/expectations/swarming.build-build-gerrit.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/yellow-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Running">
@@ -188,9 +188,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -236,13 +236,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-internal.html b/milo/frontend/expectations/swarming.build-build-internal.html
index 0db2710..5218f7d 100644
--- a/milo/frontend/expectations/swarming.build-build-internal.html
+++ b/milo/frontend/expectations/swarming.build-build-internal.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/yellow-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Running">
@@ -188,9 +188,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -236,13 +236,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-link.html b/milo/frontend/expectations/swarming.build-build-link.html
index 0c506f8..cfc2a29 100644
--- a/milo/frontend/expectations/swarming.build-build-link.html
+++ b/milo/frontend/expectations/swarming.build-build-link.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/yellow-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Running">
@@ -164,9 +164,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -212,13 +212,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-nested.html b/milo/frontend/expectations/swarming.build-build-nested.html
index 31317a8..7125d9d 100644
--- a/milo/frontend/expectations/swarming.build-build-nested.html
+++ b/milo/frontend/expectations/swarming.build-build-nested.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/yellow-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Running">
@@ -505,9 +505,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -592,13 +592,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-patch-failure.html b/milo/frontend/expectations/swarming.build-build-patch-failure.html
index 405842e..e105c28 100644
--- a/milo/frontend/expectations/swarming.build-build-patch-failure.html
+++ b/milo/frontend/expectations/swarming.build-build-patch-failure.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/red-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Failure">
@@ -276,9 +276,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -411,13 +411,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-pending-logdog.html b/milo/frontend/expectations/swarming.build-build-pending-logdog.html
index 70e3ed3..eecfb7a 100644
--- a/milo/frontend/expectations/swarming.build-build-pending-logdog.html
+++ b/milo/frontend/expectations/swarming.build-build-pending-logdog.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/gray-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-NotRun">
@@ -120,9 +120,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -165,13 +165,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-pending.html b/milo/frontend/expectations/swarming.build-build-pending.html
index 259cc39..020b95a 100644
--- a/milo/frontend/expectations/swarming.build-build-pending.html
+++ b/milo/frontend/expectations/swarming.build-build-pending.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/gray-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-NotRun">
@@ -96,9 +96,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -141,13 +141,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-running-logdog-no-annotation-stream.html b/milo/frontend/expectations/swarming.build-build-running-logdog-no-annotation-stream.html
index c89e04b..bac45d2 100644
--- a/milo/frontend/expectations/swarming.build-build-running-logdog-no-annotation-stream.html
+++ b/milo/frontend/expectations/swarming.build-build-running-logdog-no-annotation-stream.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/gray-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-NotRun">
@@ -164,9 +164,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -206,13 +206,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-running-logdog.html b/milo/frontend/expectations/swarming.build-build-running-logdog.html
index 06bff44..2b0f906 100644
--- a/milo/frontend/expectations/swarming.build-build-running-logdog.html
+++ b/milo/frontend/expectations/swarming.build-build-running-logdog.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/yellow-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Running">
@@ -242,9 +242,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -320,13 +320,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-running.html b/milo/frontend/expectations/swarming.build-build-running.html
index d2cefb3..76bffc8 100644
--- a/milo/frontend/expectations/swarming.build-build-running.html
+++ b/milo/frontend/expectations/swarming.build-build-running.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/yellow-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Running">
@@ -216,9 +216,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -294,13 +294,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-timeout.html b/milo/frontend/expectations/swarming.build-build-timeout.html
index ba5a4fd..bccdd99 100644
--- a/milo/frontend/expectations/swarming.build-build-timeout.html
+++ b/milo/frontend/expectations/swarming.build-build-timeout.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/purple-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -62,13 +64,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-InfraFailure">
@@ -230,9 +230,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -320,13 +320,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/expectations/swarming.build-build-unicode.html b/milo/frontend/expectations/swarming.build-build-unicode.html
index 1674267..bc48f58 100644
--- a/milo/frontend/expectations/swarming.build-build-unicode.html
+++ b/milo/frontend/expectations/swarming.build-build-unicode.html
@@ -25,11 +25,13 @@
 <link id="favicon" rel="shortcut icon" type="image/png" href="/static/common/favicon/yellow-32.png">
 
 
+<script>
+  const useTabs = false;
+</script>
 <script src="/static/common/js/build.js"></script>
 <link rel="stylesheet" href="/static/common/css/tabs.css" type="text/css">
 
 
-
 <body class="interface">
   <header>
     <div>
@@ -54,13 +56,11 @@
 
     <div id="tabs" style="display: none;">
       <ul>
-        <li><a href="#results-tab">Results</a></li>
-        <li><a href="#properties-tab">Properties</a></li>
-        <li><a href="#changes-tab">Changes</a></li>
-        
+        <li><a href="#overview-tab">Overview</a></li>
       </ul>
-      <div id="results-tab">
-  <div id="results">
+      <div id="overview-tab">
+        
+  <div id="results" class="column">
     <h2>Results:</h2>
     
       <p class="result status-Running">
@@ -216,9 +216,9 @@
      
 
   </div>
-</div>
-      <div id="properties-tab">
-  <div id="properties">
+
+        
+  <div id="properties" class="column">
     <h2>Build Properties:</h2>
 
     <table class="info BuildProperties" width="100%">
@@ -294,13 +294,13 @@
     </table>
 
   </div>
-</div>
-      <div id="changes-tab">
-  <div id="changes">
+
+        
+  <div id="changes" class="column">
     
   </div>
-</div>
-      
+
+      </div>
     </div>
   </div>
 
diff --git a/milo/frontend/middleware.go b/milo/frontend/middleware.go
index bbc471a..47d2704 100644
--- a/milo/frontend/middleware.go
+++ b/milo/frontend/middleware.go
@@ -25,9 +25,14 @@
 	"strings"
 	"time"
 
+	"github.com/golang/protobuf/ptypes"
+	structpb "github.com/golang/protobuf/ptypes/struct"
+	"github.com/golang/protobuf/ptypes/timestamp"
+
 	"go.chromium.org/gae/service/info"
 
 	"go.chromium.org/luci/auth/identity"
+	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
 	"go.chromium.org/luci/common/clock"
 	"go.chromium.org/luci/common/errors"
 	"go.chromium.org/luci/common/logging"
@@ -47,6 +52,9 @@
 
 // funcMap is what gets fed into the template bundle.
 var funcMap = template.FuncMap{
+	"botLink":          botLink,
+	"recipeLink":       recipeLink,
+	"duration":         duration,
 	"faviconMIMEType":  faviconMIMEType,
 	"formatCommitDesc": formatCommitDesc,
 	"formatTime":       formatTime,
@@ -58,10 +66,14 @@
 	"parseRFC3339":     parseRFC3339,
 	"percent":          percent,
 	"prefix":           prefix,
+	"renderMarkdown":   renderMarkdown,
+	"renderProperties": renderProperties,
 	"shortenEmail":     shortenEmail,
 	"startswith":       strings.HasPrefix,
 	"sub":              sub,
+	"toInterval":       toInterval,
 	"toLower":          strings.ToLower,
+	"toTime":           toTime,
 }
 
 // localTime returns a <span> element with t in human format
@@ -191,6 +203,34 @@
 	return chunks
 }
 
+// recipeLink generates a link to codesearch given a recipe bundle.
+func recipeLink(r *buildbucketpb.BuildInfra_Recipe) (result template.HTML) {
+	if r == nil {
+		return
+	}
+	// We don't know location of recipes within the repo and getting that
+	// information is not trivial, so use code search, which is precise enough.
+	csHost := "cs.chromium.org"
+	if strings.HasPrefix(r.CipdPackage, "infra_internal") {
+		csHost = "cs.corp.google.com"
+	}
+	recipeURL := fmt.Sprintf("https://%s/search/?q=file:recipes/%s.py", csHost, r.Name)
+	return ui.NewLink(r.Name, recipeURL, fmt.Sprintf("recipe %s", r.Name)).HTML()
+}
+
+// botLink generates a link to a swarming bot given a buildbucketpb.BuildInfra_Swarming struct.
+func botLink(s *buildbucketpb.BuildInfra_Swarming) (result template.HTML) {
+	for _, d := range s.GetBotDimensions() {
+		if d.Key == "id" {
+			return ui.NewLink(
+				d.Value,
+				fmt.Sprintf("https://%s/bot?id=%s", s.Hostname, d.Value),
+				fmt.Sprintf("swarming bot %s", d.Value)).HTML()
+		}
+	}
+	return ""
+}
+
 // formatCommitDesc takes a commit message and adds embellishments such as:
 // * Linkify https:// URLs
 // * Linkify bug numbers using https://crbug.com/
@@ -218,6 +258,59 @@
 	return chunksToHTML(chunks)
 }
 
+// toTime returns the time.Time format for the proto timestamp.
+// If the proto timestamp is invalid, we return a zero-ed out time.Time.
+func toTime(ts *timestamp.Timestamp) (result time.Time) {
+	// We want a zero-ed out time.Time, not one set to the epoch.
+	if t, err := ptypes.Timestamp(ts); err == nil {
+		result = t
+	}
+	return
+}
+
+type interval struct {
+	Start time.Time
+	End   time.Time
+}
+
+func (in interval) Started() bool {
+	return !in.Start.IsZero()
+}
+
+func (in interval) Ended() bool {
+	return !in.End.IsZero()
+}
+
+func (in interval) Duration() time.Duration {
+	// Only return something if the interval is complete.
+	if !(in.Ended() && in.Started()) {
+		return 0
+	}
+	// Don't return non-sensical values.
+	if d := in.End.Sub(in.Start); d > 0 {
+		return d
+	}
+	return 0
+}
+
+func toInterval(start, end *timestamp.Timestamp) (result interval) {
+	if t, err := ptypes.Timestamp(start); err == nil {
+		result.Start = t
+	}
+	if t, err := ptypes.Timestamp(end); err == nil {
+		result.End = t
+	}
+	return
+}
+
+func duration(start, end *timestamp.Timestamp) string {
+	in := toInterval(start, end)
+	if in.Started() && in.Ended() {
+		return humanDuration(in.Duration())
+	}
+	return "N/A"
+}
+
 // humanDuration translates d into a human readable string of x units y units,
 // where x and y could be in days, hours, minutes, or seconds, whichever is the
 // largest.
@@ -341,6 +434,21 @@
 	return refresh
 }
 
+// renderMarkdown renders the given text as markdown HTML.
+// TODO(hinoka): Implement me.
+func renderMarkdown(t string) (results template.HTML) {
+	return template.HTML(fmt.Sprintf("<pre>%s</pre>", template.HTMLEscapeString(t)))
+}
+
+// renderProperties renders a structpb.Struct as a properties table.
+// TODO(hinoka): Implement me.
+func renderProperties(p *structpb.Struct) (results template.HTML) {
+	if p == nil {
+		return
+	}
+	return
+}
+
 // pagedURL returns a self URL with the given cursor and limit paging options.
 // if limit is set to 0, then inherit whatever limit is set in request.  If
 // both are unspecified, then limit is omitted.
diff --git a/milo/frontend/routes_test.go b/milo/frontend/routes_test.go
index c712d66..5295030 100644
--- a/milo/frontend/routes_test.go
+++ b/milo/frontend/routes_test.go
@@ -31,6 +31,7 @@
 	"go.chromium.org/luci/auth/identity"
 	"go.chromium.org/luci/common/clock/testclock"
 	"go.chromium.org/luci/milo/buildsource/buildbot"
+	"go.chromium.org/luci/milo/buildsource/buildbucket"
 	"go.chromium.org/luci/milo/buildsource/swarming"
 	swarmingTestdata "go.chromium.org/luci/milo/buildsource/swarming/testdata"
 	"go.chromium.org/luci/milo/common"
@@ -55,6 +56,7 @@
 	allPackages = []testPackage{
 		{buildbotBuildTestData, "buildbot.build", "build_legacy.html"},
 		{buildbotBuilderTestData, "buildbot.builder", "builder.html"},
+		{buildbucketBuildTestData, "buildbucket.build", "build.html"},
 		{consoleTestData, "console", "console.html"},
 		{func() []common.TestBundle {
 			return swarmingTestdata.BuildTestData(
@@ -143,6 +145,24 @@
 	})
 }
 
+// buildbucketBuildTestData returns sample test data for build pages.
+func buildbucketBuildTestData() []common.TestBundle {
+	c := memory.Use(context.Background())
+	c, _ = testclock.UseTime(c, testclock.TestTimeUTC)
+	bundles := []common.TestBundle{}
+	for _, tc := range buildbucket.TestCases {
+		build, err := buildbucket.GetTestBuild(c, "../buildsource/buildbucket", tc)
+		if err != nil {
+			panic(fmt.Errorf("Encountered error while fetching %s.\n%s", tc, err))
+		}
+		bundles = append(bundles, common.TestBundle{
+			Description: fmt.Sprintf("Test page: %s", tc),
+			Data:        templates.Args{"BuildPage": &ui.BuildPage{Build: *build}},
+		})
+	}
+	return bundles
+}
+
 // buildbotBuildTestData returns sample test data for build pages.
 func buildbotBuildTestData() []common.TestBundle {
 	c := memory.Use(context.Background())
diff --git a/milo/frontend/ui/build.go b/milo/frontend/ui/build.go
new file mode 100644
index 0000000..accf81d
--- /dev/null
+++ b/milo/frontend/ui/build.go
@@ -0,0 +1,476 @@
+// Copyright 2018 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ui
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"html"
+	"html/template"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/golang/protobuf/ptypes"
+
+	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
+	"go.chromium.org/luci/milo/common/model"
+)
+
+// Step encapsulates a buildbucketpb.Step, and also allows it to carry
+// nesting information.
+type Step struct {
+	*buildbucketpb.Step
+	Children  []*Step
+	Collapsed bool
+}
+
+// ShortName returns the leaf name of a potentially nested step.
+// Eg. With a name of GrandParent|Parent|Child, this returns "Child"
+func (s *Step) ShortName() string {
+	parts := strings.Split(s.Name, "|")
+	if len(parts) == 0 {
+		return "ERROR: EMPTY NAME"
+	}
+	return parts[len(parts)-1]
+}
+
+// BuildPage represents a build page on Milo.
+// The core of the build page is the underlying build proto, but can contain
+// extra information depending on the context, for example a blamelist,
+// and the user's display preferences.
+type BuildPage struct {
+	// Build is the underlying build proto for the build page.
+	buildbucketpb.Build
+
+	// Blame is a list of people and commits that likely caused the build result.
+	// It is usually used as the list of commits between the previous run of the
+	// build on the same builder, and this run.
+	Blame []*Commit
+
+	// Errors contains any non-critical errors encountered while rendering the page.
+	Errors []error
+
+	// Mode to render the steps.
+	StepDisplayPref StepDisplayPref
+
+	// timelineData caches the results from Timeline().
+	timelineData string
+
+	// steps caches the result of Steps().
+	steps []*Step
+}
+
+// Steps converts the flat Steps from the underlying Build into a tree.
+// The tree is only calculated on the first call, all subsequent calls return cached information.
+// TODO(hinoka): Print nicer error messages instead of panicking for invalid build protos.
+func (bp *BuildPage) Steps() []*Step {
+	if bp.steps != nil {
+		return bp.steps
+	}
+	collapseGreen := bp.StepDisplayPref == StepDisplayDefault
+	// Use a map to store all the known steps, so that children can find their parents.
+	// This assumes that parents will always be traversed before children,
+	// which is always true in the build proto.
+	stepMap := map[string]*Step{}
+	for _, step := range bp.Build.Steps {
+		s := &Step{
+			Step:      step,
+			Collapsed: collapseGreen && step.Status == buildbucketpb.Status_SUCCESS,
+		}
+		stepMap[step.Name] = s
+		switch nameParts := strings.Split(step.Name, "|"); len(nameParts) {
+		case 0:
+			panic("Invalid build.proto: Step with missing name.")
+		case 1:
+			// Root step.
+			bp.steps = append(bp.steps, s)
+		default:
+			parentName := step.Name[:strings.LastIndex(step.Name, "|")]
+			parent, ok := stepMap[parentName]
+			if !ok {
+				panic("Invalid build.proto: Missing parent.")
+			}
+			parent.Children = append(parent.Children, s)
+		}
+	}
+	return bp.steps
+}
+
+func (bp *BuildPage) Builder() *Link {
+	if bp.Build.Builder == nil {
+		panic("Invalid build")
+	}
+	b := bp.Build.Builder
+	return NewLink(
+		b.Builder,
+		fmt.Sprintf("/p/%s/builders/%s/%s", b.Project, b.Bucket, b.Builder),
+		fmt.Sprintf("Builder %s in bucket %s", b.Builder, b.Bucket))
+}
+
+func (bp *BuildPage) BuildID() *Link {
+	if bp.Build.Builder == nil {
+		panic("invalid build")
+	}
+	num := bp.Build.Id
+	if bp.Build.Number != 0 {
+		num = int64(bp.Build.Number)
+	}
+	b := bp.Build.Builder
+	return NewLink(
+		fmt.Sprintf("%d", num),
+		fmt.Sprintf("/p/%s/builder/%s/%s/%d", b.Project, b.Bucket, b.Builder, num),
+		fmt.Sprintf("Build %d", num))
+}
+
+// Banner returns names of icons to display next to the build number.
+// Currently displayed:
+// * OS, as determined by swarming dimensions.
+// TODO(hinoka): For device builders, display device type, and number of devices.
+func (bp *BuildPage) Banners() (result []Logo) {
+	if bp.Infra == nil {
+		return
+	}
+	if bp.Infra.Swarming == nil {
+		return
+	}
+	for _, dim := range bp.Infra.Swarming.BotDimensions {
+		if dim.Key != "os" {
+			continue
+		}
+		os := dim.Value
+		parts := strings.SplitN(os, "-", 2)
+		var ver string
+		if len(parts) == 2 {
+			os = parts[0]
+			ver = parts[1]
+		}
+
+		var base LogoBase
+		switch os {
+		case "Ubuntu":
+			base = Ubuntu
+		case "Windows":
+			base = Windows
+		case "Mac":
+			base = OSX
+		case "Android":
+			base = Android
+		default:
+			return
+		}
+		result = append(result, Logo{
+			LogoBase: base,
+			Subtitle: ver,
+			Count:    1,
+		})
+	}
+	return // We didn't find an OS dimension.
+}
+
+// StepDisplayPref is the display preference for the steps.
+type StepDisplayPref string
+
+const (
+	// StepDisplayDefault means that all steps are visible, green steps are
+	// collapsed.
+	StepDisplayDefault StepDisplayPref = "default"
+	// StepDisplayExpanded means that all steps are visible, nested steps are
+	// expanded.
+	StepDisplayExpanded StepDisplayPref = "expanded"
+	// StepDisplayNonGreen means that only non-green steps are visible, nested
+	// steps are expanded.
+	StepDisplayNonGreen StepDisplayPref = "non-green"
+)
+
+// Commit represents a single commit to a repository, rendered as part of a blamelist.
+type Commit struct {
+	// Who made the commit?
+	AuthorName string
+	// Email of the committer.
+	AuthorEmail string
+	// Time of the commit.
+	CommitTime time.Time
+	// Full URL of the main source repository.
+	Repo string
+	// Branch of the repo.
+	Branch string
+	// Requested revision of the commit or base commit.
+	RequestRevision *Link
+	// Revision of the commit or base commit.
+	Revision *Link
+	// The commit message.
+	Description string
+	// Rietveld or Gerrit URL if the commit is a patch.
+	Changelist *Link
+	// Browsable URL of the commit.
+	CommitURL string
+	// List of changed filenames.
+	File []string
+}
+
+// RevisionHTML returns a single rendered link for the revision, prioritizing
+// Revision over RequestRevision.
+func (c *Commit) RevisionHTML() template.HTML {
+	switch {
+	case c == nil:
+		return ""
+	case c.Revision != nil:
+		return c.Revision.HTML()
+	case c.RequestRevision != nil:
+		return c.RequestRevision.HTML()
+	default:
+		return ""
+	}
+}
+
+// Title is the first line of the commit message (Description).
+func (c *Commit) Title() string {
+	switch lines := strings.SplitN(c.Description, "\n", 2); len(lines) {
+	case 0:
+		return ""
+	case 1:
+		return c.Description
+	default:
+		return lines[0]
+	}
+}
+
+// DescLines returns the description as a slice, one line per item.
+func (c *Commit) DescLines() []string {
+	return strings.Split(c.Description, "\n")
+}
+
+// Timeline returns a JSON parsable string that can be fed into a viz timeline component.
+// TODO(hinoka): Reimplement me.
+func (bp *BuildPage) Timeline() string {
+	// Return the cached version, if it exists already.
+	if bp.timelineData != "" {
+		return bp.timelineData
+	}
+	// TODO(hinoka): This doesn't currently return correct data.
+	return "\"timeline goes here\""
+
+	// stepData is extra data to deliver with the groups and items (see below) for the
+	// Javascript vis Timeline component.
+	type stepData struct {
+		Label           string    `json:"label"`
+		Text            string    `json:"text"`
+		Duration        string    `json:"duration"`
+		MainLink        LinkSet   `json:"mainLink"`
+		SubLink         []LinkSet `json:"subLink"`
+		StatusClassName string    `json:"statusClassName"`
+	}
+
+	// group corresponds to, and matches the shape of, a Group for the Javascript
+	// vis Timeline component http://visjs.org/docs/timeline/#groups. Data
+	// rides along as an extra property (unused by vis Timeline itself) used
+	// in client side rendering. Each Group is rendered as its own row in the
+	// timeline component on to which Items are rendered. Currently we only render
+	// one Item per Group, that is one thing per row.
+	type group struct {
+		ID   string   `json:"id"`
+		Data stepData `json:"data"`
+	}
+
+	// item corresponds to, and matches the shape of, an Item for the Javascript
+	// vis Timeline component http://visjs.org/docs/timeline/#items. Data
+	// rides along as an extra property (unused by vis Timeline itself) used
+	// in client side rendering. Each Item is rendered to a Group which corresponds
+	// to a row. Currently we only render one Item per Group, that is one thing per
+	// row.
+	type item struct {
+		ID        string   `json:"id"`
+		Group     string   `json:"group"`
+		Start     int64    `json:"start"`
+		End       int64    `json:"end"`
+		Type      string   `json:"type"`
+		ClassName string   `json:"className"`
+		Data      stepData `json:"data"`
+	}
+
+	groups := make([]group, len(bp.Build.Steps))
+	items := make([]item, len(bp.Build.Steps))
+	for i, step := range bp.Build.Steps {
+		groupID := strconv.Itoa(i)
+		statusClassName := fmt.Sprintf("status-%s", step.Status)
+		data := stepData{
+			Label: html.EscapeString(step.Name),
+			Text:  html.EscapeString(step.SummaryMarkdown),
+			// TODO(hinoka): HumanDuration
+			Duration:        "duration goes here",
+			StatusClassName: statusClassName,
+		}
+		groups[i] = group{groupID, data}
+		start, _ := ptypes.Timestamp(step.StartTime)
+		end, _ := ptypes.Timestamp(step.EndTime)
+		items[i] = item{
+			ID:        groupID,
+			Group:     groupID,
+			Start:     milliseconds(start),
+			End:       milliseconds(end),
+			Type:      "range",
+			ClassName: statusClassName,
+			Data:      data,
+		}
+	}
+
+	timeline, err := json.Marshal(map[string]interface{}{
+		"groups": groups,
+		"items":  items,
+	})
+	if err != nil {
+		bp.Errors = append(bp.Errors, err)
+		return "error"
+	}
+	return string(timeline)
+}
+
+func sanitize(values []string) []string {
+	result := make([]string, len(values))
+	for i, value := range values {
+		result[i] = html.EscapeString(value)
+	}
+	return result
+}
+
+func sanitizeLinkSet(linkSet LinkSet) LinkSet {
+	result := make(LinkSet, len(linkSet))
+	for i, link := range linkSet {
+		result[i] = &Link{
+			Link: model.Link{
+				Label: html.EscapeString(link.Label),
+				URL:   html.EscapeString(link.URL),
+			},
+			AriaLabel: html.EscapeString(link.AriaLabel),
+			Img:       html.EscapeString(link.Img),
+			Alt:       html.EscapeString(link.Alt),
+		}
+	}
+	return result
+}
+
+func sanitizeLinkSets(linkSets []LinkSet) []LinkSet {
+	result := make([]LinkSet, len(linkSets))
+	for i, linkSet := range linkSets {
+		result[i] = sanitizeLinkSet(linkSet)
+	}
+	return result
+}
+
+// milliseconds returns the given time in number of milliseconds elapsed since epoch.
+func milliseconds(time time.Time) int64 {
+	return time.UnixNano() / 1e6
+}
+
+/// HTML methods.
+
+var (
+	linkifyTemplate = template.Must(
+		template.New("linkify").
+			Parse(
+				`<a{{if .URL}} href="{{.URL}}"{{end}}` +
+					`{{if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}` +
+					`{{if .Alt}}{{if not .Img}} title="{{.Alt}}"{{end}}{{end}}>` +
+					`{{if .Img}}<img src="{{.Img}}"{{if .Alt}} alt="{{.Alt}}"{{end}}>` +
+					`{{else if .Alias}}[{{.Label}}]` +
+					`{{else}}{{.Label}}{{end}}` +
+					`</a>`))
+
+	linkifySetTemplate = template.Must(
+		template.New("linkifySet").
+			Parse(
+				`{{ range $i, $link := . }}` +
+					`{{ if gt $i 0 }} {{ end }}` +
+					`{{ $link.HTML }}` +
+					`{{ end }}`))
+)
+
+// HTML renders this Link as HTML.
+func (l *Link) HTML() template.HTML {
+	if l == nil {
+		return ""
+	}
+	buf := bytes.Buffer{}
+	if err := linkifyTemplate.Execute(&buf, l); err != nil {
+		panic(err)
+	}
+	return template.HTML(buf.Bytes())
+}
+
+// String renders this Link's Label as a string.
+func (l *Link) String() string {
+	if l == nil {
+		return ""
+	}
+	return l.Label
+}
+
+// HTML renders this LinkSet as HTML.
+func (l LinkSet) HTML() template.HTML {
+	if len(l) == 0 {
+		return ""
+	}
+	buf := bytes.Buffer{}
+	if err := linkifySetTemplate.Execute(&buf, l); err != nil {
+		panic(err)
+	}
+	return template.HTML(buf.Bytes())
+}
+
+// Link denotes a single labeled link.
+//
+// JSON tags here are for test expectations.
+type Link struct {
+	model.Link
+
+	// AriaLabel is a spoken label for the link.  Used as aria-label under the anchor tag.
+	AriaLabel string `json:",omitempty"`
+
+	// Img is an icon for the link.  Not compatible with label.  Rendered as <img>
+	Img string `json:",omitempty"`
+
+	// Alt text for the image, or title text with text link.
+	Alt string `json:",omitempty"`
+
+	// Alias, if true, means that this link is an [alias link].
+	Alias bool `json:",omitempty"`
+}
+
+// NewLink does just about what you'd expect.
+func NewLink(label, url, ariaLabel string) *Link {
+	return &Link{Link: model.Link{Label: label, URL: url}, AriaLabel: ariaLabel}
+}
+
+// NewPatchLink is the right way (TM) to generate links to Rietveld/Gerrit CLs.
+//
+// Returns nil if provided buildset is not Rietveld or Gerrit CL.
+func NewPatchLink(cl buildbucketpb.BuildSet) *Link {
+	switch v := cl.(type) {
+	case *buildbucketpb.GerritChange:
+		return NewLink(
+			fmt.Sprintf("Gerrit CL %d (ps#%d)", v.Change, v.Patchset),
+			v.URL(),
+			fmt.Sprintf("gerrit changelist number %d patchset %d", v.Change, v.Patchset))
+	default:
+		return nil
+	}
+}
+
+// NewEmptyLink creates a Link struct acting as a pure text label.
+func NewEmptyLink(label string) *Link {
+	return &Link{Link: model.Link{Label: label}}
+}
diff --git a/milo/frontend/ui/build_legacy.go b/milo/frontend/ui/build_legacy.go
index 64f892c..abd2c77 100644
--- a/milo/frontend/ui/build_legacy.go
+++ b/milo/frontend/ui/build_legacy.go
@@ -17,35 +17,16 @@
 package ui
 
 import (
-	"bytes"
 	"context"
 	"encoding/json"
-	"fmt"
-	"html/template"
 	"regexp"
 	"strings"
 	"time"
 
-	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
 	"go.chromium.org/luci/common/clock"
 	"go.chromium.org/luci/milo/common/model"
 )
 
-// StepDisplayPref is the display preference for the steps.
-type StepDisplayPref string
-
-const (
-	// StepDisplayDefault means that all steps are visible, green steps are
-	// collapsed.
-	StepDisplayDefault StepDisplayPref = "default"
-	// StepDisplayExpanded means that all steps are visible, nested steps are
-	// expanded.
-	StepDisplayExpanded StepDisplayPref = "expanded"
-	// StepDisplayNonGreen means that only non-green steps are visible, nested
-	// steps are expanded.
-	StepDisplayNonGreen StepDisplayPref = "non-green"
-)
-
 // MiloBuildLegacy denotes a full renderable Milo build page.
 // This is slated to be deprecated in April 2019 after the BuildBot turndown.
 // This is to be replaced by a new BuildPage struct,
@@ -244,64 +225,6 @@
 }
 func (p PropertyGroup) Less(i, j int) bool { return p.Property[i].Key < p.Property[j].Key }
 
-// Commit represents a single commit to a repository, rendered as part of a blamelist.
-type Commit struct {
-	// Who made the commit?
-	AuthorName string
-	// Email of the committer.
-	AuthorEmail string
-	// Time of the commit.
-	CommitTime time.Time
-	// Full URL of the main source repository.
-	Repo string
-	// Branch of the repo.
-	Branch string
-	// Requested revision of the commit or base commit.
-	RequestRevision *Link
-	// Revision of the commit or base commit.
-	Revision *Link
-	// The commit message.
-	Description string
-	// Rietveld or Gerrit URL if the commit is a patch.
-	Changelist *Link
-	// Browsable URL of the commit.
-	CommitURL string
-	// List of changed filenames.
-	File []string
-}
-
-// RevisionHTML returns a single rendered link for the revision, prioritizing
-// Revision over RequestRevision.
-func (c *Commit) RevisionHTML() template.HTML {
-	switch {
-	case c == nil:
-		return ""
-	case c.Revision != nil:
-		return c.Revision.HTML()
-	case c.RequestRevision != nil:
-		return c.RequestRevision.HTML()
-	default:
-		return ""
-	}
-}
-
-// Title is the first line of the commit message (Description).
-func (c *Commit) Title() string {
-	switch lines := strings.SplitN(c.Description, "\n", 2); len(lines) {
-	case 0:
-		return ""
-	case 1:
-		return c.Description
-	default:
-		return lines[0]
-	}
-}
-
-// DescLines returns the description as a slice, one line per item.
-func (c *Commit) DescLines() []string {
-	return strings.Split(c.Description, "\n")
-}
-
 // BuildProgress is a way to show progress.  Percent should always be specified.
 type BuildProgress struct {
 	// The total number of entries. Shows up as a tooltip.  Leave at 0 to
@@ -455,102 +378,3 @@
 // LinkSet is an ordered collection of Link objects that will be rendered on the
 // same line.
 type LinkSet []*Link
-
-// Link denotes a single labeled link.
-//
-// JSON tags here are for test expectations.
-type Link struct {
-	model.Link
-
-	// AriaLabel is a spoken label for the link.  Used as aria-label under the anchor tag.
-	AriaLabel string `json:",omitempty"`
-
-	// Img is an icon for the link.  Not compatible with label.  Rendered as <img>
-	Img string `json:",omitempty"`
-
-	// Alt text for the image, or title text with text link.
-	Alt string `json:",omitempty"`
-
-	// Alias, if true, means that this link is an [alias link].
-	Alias bool `json:",omitempty"`
-}
-
-// NewLink does just about what you'd expect.
-func NewLink(label, url, ariaLabel string) *Link {
-	return &Link{Link: model.Link{Label: label, URL: url}, AriaLabel: ariaLabel}
-}
-
-// NewPatchLink is the right way (TM) to generate links to Rietveld/Gerrit CLs.
-//
-// Returns nil if provided buildset is not Rietveld or Gerrit CL.
-func NewPatchLink(cl buildbucketpb.BuildSet) *Link {
-	switch v := cl.(type) {
-	case *buildbucketpb.GerritChange:
-		return NewLink(
-			fmt.Sprintf("Gerrit CL %d (ps#%d)", v.Change, v.Patchset),
-			v.URL(),
-			fmt.Sprintf("gerrit changelist number %d patchset %d", v.Change, v.Patchset))
-	default:
-		return nil
-	}
-}
-
-// NewEmptyLink creates a Link struct acting as a pure text label.
-func NewEmptyLink(label string) *Link {
-	return &Link{Link: model.Link{Label: label}}
-}
-
-/// HTML methods.
-
-var (
-	linkifyTemplate = template.Must(
-		template.New("linkify").
-			Parse(
-				`<a{{if .URL}} href="{{.URL}}"{{end}}` +
-					`{{if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}` +
-					`{{if .Alt}}{{if not .Img}} title="{{.Alt}}"{{end}}{{end}}>` +
-					`{{if .Img}}<img src="{{.Img}}"{{if .Alt}} alt="{{.Alt}}"{{end}}>` +
-					`{{else if .Alias}}[{{.Label}}]` +
-					`{{else}}{{.Label}}{{end}}` +
-					`</a>`))
-
-	linkifySetTemplate = template.Must(
-		template.New("linkifySet").
-			Parse(
-				`{{ range $i, $link := . }}` +
-					`{{ if gt $i 0 }} {{ end }}` +
-					`{{ $link.HTML }}` +
-					`{{ end }}`))
-)
-
-// HTML renders this Link as HTML.
-func (l *Link) HTML() template.HTML {
-	if l == nil {
-		return ""
-	}
-	buf := bytes.Buffer{}
-	if err := linkifyTemplate.Execute(&buf, l); err != nil {
-		panic(err)
-	}
-	return template.HTML(buf.Bytes())
-}
-
-// String renders this Link's Label as a string.
-func (l *Link) String() string {
-	if l == nil {
-		return ""
-	}
-	return l.Label
-}
-
-// HTML renders this LinkSet as HTML.
-func (l LinkSet) HTML() template.HTML {
-	if len(l) == 0 {
-		return ""
-	}
-	buf := bytes.Buffer{}
-	if err := linkifySetTemplate.Execute(&buf, l); err != nil {
-		panic(err)
-	}
-	return template.HTML(buf.Bytes())
-}
diff --git a/milo/frontend/view_build.go b/milo/frontend/view_build.go
index 942abd2..facef0c 100644
--- a/milo/frontend/view_build.go
+++ b/milo/frontend/view_build.go
@@ -1,3 +1,17 @@
+// Copyright 2018 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
 package frontend
 
 import (
@@ -6,32 +20,67 @@
 	"strconv"
 	"strings"
 
+	bb "go.chromium.org/luci/buildbucket"
+	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
 	bbv1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1"
 	"go.chromium.org/luci/common/data/strpair"
 	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
 	"go.chromium.org/luci/milo/buildsource/buildbucket"
 	"go.chromium.org/luci/milo/common"
+	"go.chromium.org/luci/milo/frontend/ui"
 	"go.chromium.org/luci/server/router"
+	"go.chromium.org/luci/server/templates"
 )
 
 // handleLUCIBuild renders a LUCI build.
 func handleLUCIBuild(c *router.Context) error {
 	bucket := c.Params.ByName("bucket")
-	builder := c.Params.ByName("builder")
+	buildername := c.Params.ByName("builder")
 	numberOrId := c.Params.ByName("numberOrId")
 
-	var address string
-	if strings.HasPrefix(numberOrId, "b") {
-		address = numberOrId[1:]
-	} else {
-		address = fmt.Sprintf("%s/%s/%s", bucket, builder, numberOrId)
+	if _, v2Bucket := bb.BucketNameToV2(bucket); v2Bucket != "" {
+		// Params bucket is a v1 bucket, so call the legacy endpoint.
+		return handleLUCIBuildLegacy(c, bucket, buildername, numberOrId)
 	}
 
-	build, err := buildbucket.GetBuild(c.Context, address, true)
-	// TODO(nodir): after switching to API v2, check that project, bucket
-	// and builder in parameters indeed match the returned build. This is
-	// relevant when the build is loaded by id.
-	return renderBuildLegacy(c, build, true, err)
+	// TODO(hinoka): Once v2 is default, redirect v1 bucketnames to v2 bucketname URLs.
+	br := buildbucketpb.GetBuildRequest{}
+	if strings.HasPrefix(numberOrId, "b") {
+		id, err := strconv.ParseInt(numberOrId[1:], 10, 64)
+		if err != nil {
+			return errors.Annotate(err, "bad build id").Tag(common.CodeParameterError).Err()
+		}
+		br.Id = int64(id)
+	} else {
+		number, err := strconv.Atoi(numberOrId)
+		if err != nil {
+			return errors.Annotate(err, "bad build number").Tag(common.CodeParameterError).Err()
+		}
+		br.BuildNumber = int32(number)
+		br.Builder = &buildbucketpb.BuilderID{
+			Project: c.Params.ByName("project"),
+			Bucket:  bucket,
+			Builder: buildername,
+		}
+	}
+
+	bp, err := buildbucket.GetBuildPage(c.Context, br)
+	return renderBuild(c, bp, err)
+}
+
+// renderBuild is a shortcut for rendering build or returning err if it is not nil.
+func renderBuild(c *router.Context, bp *ui.BuildPage, err error) error {
+	if err != nil {
+		return err
+	}
+
+	bp.StepDisplayPref = getStepDisplayPrefCookie(c)
+
+	templates.MustRender(c.Context, c.Writer, "pages/build.html", templates.Args{
+		"BuildPage": bp,
+	})
+	return nil
 }
 
 // redirectLUCIBuild redirects to a canonical build URL
@@ -73,3 +122,15 @@
 	http.Redirect(c.Writer, c.Request, u.String(), http.StatusMovedPermanently)
 	return nil
 }
+
+func getStepDisplayPrefCookie(c *router.Context) ui.StepDisplayPref {
+	switch cookie, err := c.Request.Cookie("stepDisplayPref"); err {
+	case nil:
+		return ui.StepDisplayPref(cookie.Value)
+	case http.ErrNoCookie:
+		return ui.StepDisplayDefault
+	default:
+		logging.WithError(err).Errorf(c.Context, "failed to read stepDisplayPref cookie")
+		return ui.StepDisplayDefault
+	}
+}
diff --git a/milo/frontend/view_build_legacy.go b/milo/frontend/view_build_legacy.go
index 20ba21e..cf44fef 100644
--- a/milo/frontend/view_build_legacy.go
+++ b/milo/frontend/view_build_legacy.go
@@ -1,18 +1,25 @@
-// Copyright 2017 The LUCI Authors. All rights reserved.
-// Use of this source code is governed under the Apache License, Version 2.0
-// that can be found in the LICENSE file.
+// Copyright 2018 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
 
 package frontend
 
 import (
-	"encoding/json"
 	"fmt"
-	"html"
 	"net/http"
 	"reflect"
 	"strconv"
 	"strings"
-	"time"
 
 	"github.com/julienschmidt/httprouter"
 	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
@@ -26,10 +33,10 @@
 	"go.chromium.org/luci/milo/api/config"
 	"go.chromium.org/luci/milo/buildsource/buildbot"
 	"go.chromium.org/luci/milo/buildsource/buildbot/buildstore"
+	"go.chromium.org/luci/milo/buildsource/buildbucket"
 	"go.chromium.org/luci/milo/buildsource/rawpresentation"
 	"go.chromium.org/luci/milo/buildsource/swarming"
 	"go.chromium.org/luci/milo/common"
-	"go.chromium.org/luci/milo/common/model"
 	"go.chromium.org/luci/milo/frontend/ui"
 )
 
@@ -67,6 +74,19 @@
 	}
 }
 
+// handleLUCIBuildLegacy renders a LUCI build.
+func handleLUCIBuildLegacy(c *router.Context, bucket, builder, numberOrId string) error {
+	var address string
+	if strings.HasPrefix(numberOrId, "b") {
+		address = numberOrId[1:]
+	} else {
+		address = fmt.Sprintf("%s/%s/%s", bucket, builder, numberOrId)
+	}
+
+	build, err := buildbucket.GetBuildLegacy(c.Context, address, true)
+	return renderBuildLegacy(c, build, true, err)
+}
+
 func handleSwarmingBuild(c *router.Context) error {
 	build, err := swarming.GetBuild(
 		c.Context,
@@ -84,18 +104,6 @@
 	return renderBuildLegacy(c, build, false, err)
 }
 
-func getStepDisplayPrefCookie(c *router.Context) ui.StepDisplayPref {
-	switch cookie, err := c.Request.Cookie("stepDisplayPref"); err {
-	case nil:
-		return ui.StepDisplayPref(cookie.Value)
-	case http.ErrNoCookie:
-		return ui.StepDisplayDefault
-	default:
-		logging.WithError(err).Errorf(c.Context, "failed to read stepDisplayPref cookie")
-		return ui.StepDisplayDefault
-	}
-}
-
 // renderBuildLegacy is a shortcut for rendering build or returning err if it is not
 // nil. Also calls build.Fix().
 func renderBuildLegacy(c *router.Context, build *ui.MiloBuildLegacy, renderTimeline bool, err error) error {
@@ -106,135 +114,13 @@
 	build.StepDisplayPref = getStepDisplayPrefCookie(c)
 	build.Fix(c.Context)
 
-	timelineJSON := ""
-	if renderTimeline {
-		if timelineJSON, err = timelineData(build); err != nil {
-			return err
-		}
-	}
-
 	templates.MustRender(c.Context, c.Writer, "pages/build_legacy.html", templates.Args{
 		"Build":             build,
-		"TimelineJSON":      timelineJSON,
 		"BuildFeedbackLink": makeFeedbackLink(c, build),
 	})
 	return nil
 }
 
-// timelineData returns the timelineJSON for a vis timeline timeline
-// as a JSON.parse parseable string that will contain the necessary
-// Groups and Items.
-func timelineData(build *ui.MiloBuildLegacy) (string, error) {
-	// stepData is extra data to deliver with the groups and items (see below) for the
-	// Javascript vis Timeline component.
-	type stepData struct {
-		Label           string       `json:"label"`
-		Text            []string     `json:"text"`
-		Duration        string       `json:"duration"`
-		MainLink        ui.LinkSet   `json:"mainLink"`
-		SubLink         []ui.LinkSet `json:"subLink"`
-		StatusClassName string       `json:"statusClassName"`
-	}
-
-	// group corresponds to, and matches the shape of, a Group for the Javascript
-	// vis Timeline component http://visjs.org/docs/timeline/#groups. Data
-	// rides along as an extra property (unused by vis Timeline itself) used
-	// in client side rendering. Each Group is rendered as its own row in the
-	// timeline component on to which Items are rendered. Currently we only render
-	// one Item per Group, that is one thing per row.
-	type group struct {
-		ID   string   `json:"id"`
-		Data stepData `json:"data"`
-	}
-
-	// item corresponds to, and matches the shape of, an Item for the Javascript
-	// vis Timeline component http://visjs.org/docs/timeline/#items. Data
-	// rides along as an extra property (unused by vis Timeline itself) used
-	// in client side rendering. Each Item is rendered to a Group which corresponds
-	// to a row. Currently we only render one Item per Group, that is one thing per
-	// row.
-	type item struct {
-		ID        string   `json:"id"`
-		Group     string   `json:"group"`
-		Start     int64    `json:"start"`
-		End       int64    `json:"end"`
-		Type      string   `json:"type"`
-		ClassName string   `json:"className"`
-		Data      stepData `json:"data"`
-	}
-
-	groups := make([]group, len(build.Components))
-	items := make([]item, len(build.Components))
-	for index, comp := range build.Components {
-		groupID := strconv.Itoa(index)
-		statusClassName := fmt.Sprintf("status-%s", comp.Status)
-		data := stepData{
-			Label:           html.EscapeString(comp.Label.Label),
-			Text:            sanitize(comp.TextBR()),
-			Duration:        humanDuration(comp.ExecutionTime.Duration),
-			MainLink:        sanitizeLinkSet(comp.MainLink),
-			SubLink:         sanitizeLinkSets(comp.SubLink),
-			StatusClassName: statusClassName,
-		}
-		groups[index] = group{groupID, data}
-		items[index] = item{
-			ID:        groupID,
-			Group:     groupID,
-			Start:     milliseconds(comp.ExecutionTime.Started),
-			End:       milliseconds(comp.ExecutionTime.Finished),
-			Type:      "range",
-			ClassName: statusClassName,
-			Data:      data,
-		}
-	}
-
-	timeline, err := json.Marshal(map[string]interface{}{
-		"groups": groups,
-		"items":  items,
-	})
-	if err != nil {
-		return "", err
-	}
-
-	return string(timeline), nil
-}
-
-func sanitize(values []string) []string {
-	result := make([]string, len(values))
-	for i, value := range values {
-		result[i] = html.EscapeString(value)
-	}
-	return result
-}
-
-func sanitizeLinkSet(linkSet ui.LinkSet) ui.LinkSet {
-	result := make(ui.LinkSet, len(linkSet))
-	for i, link := range linkSet {
-		result[i] = &ui.Link{
-			Link: model.Link{
-				Label: html.EscapeString(link.Label),
-				URL:   html.EscapeString(link.URL),
-			},
-			AriaLabel: html.EscapeString(link.AriaLabel),
-			Img:       html.EscapeString(link.Img),
-			Alt:       html.EscapeString(link.Alt),
-		}
-	}
-	return result
-}
-
-func sanitizeLinkSets(linkSets []ui.LinkSet) []ui.LinkSet {
-	result := make([]ui.LinkSet, len(linkSets))
-	for i, linkSet := range linkSets {
-		result[i] = sanitizeLinkSet(linkSet)
-	}
-	return result
-}
-
-func milliseconds(time time.Time) int64 {
-	return time.UnixNano() / 1e6
-}
-
 // makeFeedbackLink attempts to create the feedback link for the build page. If the
 // project is not configured for a custom feedback link or an interpolation placeholder
 // cannot be satisfied an empty string is returned.