cmd/coordinator: render partial build dashboard

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

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

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

Updates golang/go#34744

Change-Id: I6ffe064b9fc5e4a3271eadfd5ac45d5baf4ebd37
Reviewed-on: https://go-review.googlesource.com/c/build/+/221920
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
This commit is contained in:
Alexander Rakoczy 2020-02-20 10:38:56 -05:00
Родитель ef9e68dfbd
Коммит 236dbea5aa
11 изменённых файлов: 996 добавлений и 88 удалений

Просмотреть файл

@ -54,11 +54,15 @@ type Environment struct {
// This field may be overridden as necessary without impacting other fields.
ProjectName string
// ProjectNumber is the GCP project's number, as visible in the admin console.
// This is used for things such as constructing the "email" of the default
// service account.
// ProjectNumber is the GCP build infrastructure project's number, as visible
// in the admin console. This is used for things such as constructing the
// "email" of the default service account.
ProjectNumber int64
// The GCP project name for the Go project, where build status is stored.
// This field may be overridden as necessary without impacting other fields.
GoProjectName string
// The IsProd flag indicates whether production functionality should be
// enabled. When true, GCE and Kubernetes builders are enabled and the
// coordinator serves on 443. Otherwise, GCE and Kubernetes builders are
@ -231,6 +235,7 @@ func ByProjectID(projectID string) *Environment {
var Staging = &Environment{
ProjectName: "go-dashboard-dev",
ProjectNumber: 302018677728,
GoProjectName: "go-dashboard-dev",
IsProd: true,
ControlZone: "us-central1-f",
VMZones: []string{"us-central1-a", "us-central1-b", "us-central1-c", "us-central1-f"},
@ -263,6 +268,7 @@ var Staging = &Environment{
var Production = &Environment{
ProjectName: "symbolic-datum-552",
ProjectNumber: 872405196845,
GoProjectName: "golang-org",
IsProd: true,
ControlZone: "us-central1-f",
VMZones: []string{"us-central1-a", "us-central1-b", "us-central1-c", "us-central1-f"},
@ -292,8 +298,9 @@ var Production = &Environment{
}
var Development = &Environment{
IsProd: false,
StaticIP: "127.0.0.1",
GoProjectName: "golang-org",
IsProd: false,
StaticIP: "127.0.0.1",
}
// possibleEnvs enumerate the known buildenv.Environment definitions.

Просмотреть файл

@ -86,6 +86,8 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /go/src/golang.org/x/build/cmd/coordinator/internal/dashboard/dashboard.html /dashboard.html
COPY --from=build /go/src/golang.org/x/build/cmd/coordinator/style.css /style.css
COPY --from=build /go/bin/coordinator /
COPY --from=build_drawterm /usr/local/bin/drawterm /usr/local/bin/

Просмотреть файл

@ -45,6 +45,7 @@ import (
"unicode"
"go4.org/syncutil"
builddash "golang.org/x/build/cmd/coordinator/internal/dashboard"
"golang.org/x/build/cmd/coordinator/protos"
"google.golang.org/grpc"
grpc4 "grpc.go4.org"
@ -305,6 +306,11 @@ func main() {
}
maintnerClient = apipb.NewMaintnerServiceClient(cc)
if err := loadStatic(); err != nil {
log.Printf("Failed to load static resources: %v", err)
}
dh := &builddash.Handler{Datastore: goDSClient, Maintner: maintnerClient}
gs := &gRPCServer{dashboardURL: "https://build.golang.org"}
protos.RegisterCoordinatorServer(grpcServer, gs)
http.HandleFunc("/", handleStatus)
@ -319,6 +325,7 @@ func main() {
http.HandleFunc("/try.json", serveTryStatus(true))
http.HandleFunc("/status/reverse.json", reversePool.ServeReverseStatusJSON)
http.HandleFunc("/status/post-submit-active.json", handlePostSubmitActiveJSON)
http.Handle("/dashboard", dh)
http.Handle("/buildlet/create", requireBuildletProxyAuth(http.HandlerFunc(handleBuildletCreate)))
http.Handle("/buildlet/list", requireBuildletProxyAuth(http.HandlerFunc(handleBuildletList)))
go func() {

Просмотреть файл

@ -42,7 +42,7 @@ import (
"golang.org/x/build/internal/lru"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
compute "google.golang.org/api/compute/v1"
"google.golang.org/api/compute/v1"
"google.golang.org/api/googleapi"
)
@ -63,7 +63,10 @@ func gceAPIGate() {
var (
buildEnv *buildenv.Environment
dsClient *datastore.Client
// dsClient is a datastore client for the build project (symbolic-datum-552), where build progress is stored.
dsClient *datastore.Client
// goDSClient is a datastore client for golang-org, where build status is stored.
goDSClient *datastore.Client
computeService *compute.Service
gcpCreds *google.Credentials
errTryDeps error // non-nil if try bots are disabled
@ -100,7 +103,7 @@ func initGCE() error {
}
buildEnv = buildenv.ByProjectID(*buildEnvName)
inStaging = (buildEnv == buildenv.Staging)
inStaging = buildEnv == buildenv.Staging
// If running on GCE, override the zone and static IP, and check service account permissions.
if metadata.OnGCE() {
@ -153,9 +156,17 @@ func initGCE() error {
dsClient, err = datastore.NewClient(ctx, buildEnv.ProjectName)
if err != nil {
if *mode == "dev" {
log.Printf("Error creating datastore client: %v", err)
log.Printf("Error creating datastore client for %q: %v", buildEnv.ProjectName, err)
} else {
log.Fatalf("Error creating datastore client: %v", err)
log.Fatalf("Error creating datastore client for %q: %v", buildEnv.ProjectName, err)
}
}
goDSClient, err = datastore.NewClient(ctx, buildEnv.GoProjectName)
if err != nil {
if *mode == "dev" {
log.Printf("Error creating datastore client for %q: %v", buildEnv.GoProjectName, err)
} else {
log.Fatalf("Error creating datastore client for %q: %v", buildEnv.GoProjectName, err)
}
}

Просмотреть файл

@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{$.Dashboard.Name}} Build Dashboard</title>
<link rel="stylesheet" href="/style.css"/>
<script async>
let showUnsupported = window.location.hash.substr(1) !== 'short';
function redraw() {
showUnsupported = !document.querySelector('#showshort').checked;
document.querySelectorAll('.unsupported').forEach(el => {
el.hidden = !showUnsupported;
});
window.location.hash = showUnsupported ? '' : 'short';
document.querySelectorAll('.Build-builderOS').forEach(el => {
el.setAttribute(
'colspan',
el.getAttribute(
showUnsupported ? 'data-archs' : 'data-firstClassArchs'
)
);
});
document.querySelectorAll('.Build-osColumn').forEach(el => {
el.setAttribute(
'span',
el.getAttribute(
showUnsupported ? 'data-archs' : 'data-firstClassArchs'
)
);
});
}
window.addEventListener('load', () => {
document.querySelector('#showshort').checked = !showUnsupported;
document.querySelector('#showshort').addEventListener('change', redraw);
redraw();
});
</script>
</head>
<body class="Dashboard">
<header class="Dashboard-topbar">
<h1>Go Dashboard</h1>
</header>
<form action="../.." method="GET">
<input type="hidden" name="repo" value="{{.Package.Path}}"/>
<nav class="Dashboard-controls">
{{if not (eq .Branch "")}}
<label>
<select name="branch" onchange="this.form.submit()">
{{range $.Branches}}
<option value="{{.}}" {{if eq $.Branch .}} selected{{end}}>
{{.}}
</option>
{{end}}
</select>
</label>
{{end}}
<label>
<input type="checkbox" id="showshort"/>
show only
<a href="http://golang.org/wiki/PortingPolicy">first-class ports</a>
</label>
</nav>
</form>
<h2 class="Dashboard-packageName">{{$.Package.Name}}</h2>
<div class="page Build-scrollTable">
{{if $.Commits}}
<table class="Build">
<colgroup class="col-hash" {{if $.Package.Path}} span="2" {{end}}></colgroup>
<colgroup class="col-user"></colgroup>
<colgroup class="col-time"></colgroup>
<colgroup class="Build-descriptionColumn col-desc"></colgroup>
{{range $.Builders}}
<colgroup class="Build-osColumn col-result{{if .Unsupported}} unsupported{{end}}" span="{{.Archs | len}}"
data-archs="{{.Archs | len}}" data-firstClassArchs="{{.FirstClassArchs | len}}"></colgroup>
{{end}}
<tr class="Build-builderOSRow">
{{if $.Package.Path}}
<th colspan="2">revision</th>
{{else}}
<th>&nbsp;</th>
{{end}}
<th></th>
<th></th>
<th></th>
{{range $.Builders}}
<th class="Build-builderOS{{if not .FirstClass}} unsupported{{end}}" colspan="{{.Archs | len}}"
data-archs="{{.Archs | len}}" data-firstClassArchs="{{.FirstClassArchs | len}}">
{{.OS}}
</th>
{{end}}
</tr>
<tr class="Build-builderArchRow">
{{if $.Package.Path}}
<th class="result arch">repo</th>
<th class="result arch">{{$.Dashboard.Name}}</th>
{{else}}
<th>&nbsp;</th>
{{end}}
<th></th>
<th></th>
<th></th>
{{range $.Builders}}
{{range.Archs}}
<th class="result arch{{if not (.FirstClass)}} unsupported{{end}}" title="{{.Name}}">
{{.Arch}}
</th>
{{end}}
{{end}}
</tr>
<tr class="Build-builderTagRow">
<th {{if $.Package.Path}}colspan="2" {{end}}>&nbsp;</th>
<th></th>
<th></th>
<th></th>
{{range $.Builders}}
{{range.Archs}}
<th class="Build-resultArch result arch{{if not (.FirstClass)}} unsupported{{end}}" title="{{.Name}}">
{{.Tag}}
</th>
{{end}}
{{end}}
</tr>
{{range $c := $.Commits}}
<tr class="commit">
<td class="hash">
<span class="ShortHash">
<a href="https://go-review.googlesource.com/q/{{$c.Hash}}">
{{$c.Hash}}
</a>
</span>
</td>
<td class="Build-user" title="{{$c.User}}">{{$c.ShortUser}}</td>
<td class="Build-commitTime">
{{$c.Time}}
</td>
<td class="Build-desc desc" title="{{$c.Desc}}">{{$c.Desc}}</td>
{{range $b := $.Builders}}
{{range $a := .Archs}}
<td class="{{if not $a.FirstClass}} unsupported{{end}}" data-builder="{{$a.Name}}">
{{with $c.ResultForBuilder $a.Name}}
{{if .OK}} ok {{end}}
{{end}}
</td>
{{end}}
{{end}}
</tr>
{{end}}
</table>
{{with $.Pagination}}
<div class="paginate">
<nav>
{{if .HasPrev}}
<a href="?repo={{$.Package.Path}}&page={{.Prev}}&branch={{$.Branch}}">
newer
</a>
{{else}}
newer
{{end}}
{{if .Next}}
<a href="?repo={{$.Package.Path}}&page={{.Next}}branch={{$.Branch}}">
older
</a>
{{else}}
older
{{end}}
{{if .HasPrev}}
<a href="?branch={{$.Branch}}">
latest
</a>
{{else}}
latest
{{end}}
</nav>
</div>
{{end}}
{{else}}
<p>No commits to display. Hm.</p>
{{end}}
</div>
</body>
</html>

Просмотреть файл

@ -0,0 +1,44 @@
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build go1.13
// +build linux darwin
package dashboard
import (
"context"
"log"
"cloud.google.com/go/datastore"
)
// getDatastoreResults populates result data on commits, fetched from Datastore.
func getDatastoreResults(ctx context.Context, cl *datastore.Client, commits []*commit, pkg string) {
var keys []*datastore.Key
for _, c := range commits {
pkey := datastore.NameKey("Package", pkg, nil)
pkey.Namespace = "Git"
key := datastore.NameKey("Commit", "|"+c.Hash, pkey)
key.Namespace = "Git"
keys = append(keys, key)
}
out := make([]*Commit, len(keys))
if err := cl.GetMulti(ctx, keys, out); err != nil {
log.Printf("getResults: error fetching %d results: %v", len(keys), err)
return
}
hashOut := make(map[string]*Commit)
for _, o := range out {
if o != nil && o.Hash != "" {
hashOut[o.Hash] = o
}
}
for _, c := range commits {
if result, ok := hashOut[c.Hash]; ok {
c.ResultData = result.ResultData
}
}
return
}

Просмотреть файл

@ -0,0 +1,313 @@
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build go1.13
// +build linux darwin
// Package dashboard contains the implementation of the build dashboard for the Coordinator.
package dashboard
import (
"bytes"
"context"
"fmt"
"html/template"
"log"
"net/http"
"sort"
"strings"
"time"
"cloud.google.com/go/datastore"
"golang.org/x/build/cmd/coordinator/internal"
"golang.org/x/build/dashboard"
"golang.org/x/build/maintner/maintnerd/apipb"
grpc4 "grpc.go4.org"
)
var firstClassPorts = map[string]bool{
"darwin-amd64": true,
"linux-386": true,
"linux-amd64": true,
"linux-arm": true,
"linux-arm64": true,
"windows-386": true,
"windows-amd64": true,
}
type data struct {
Branch string
Builders []*builder
Commits []*commit
Dashboard struct {
Name string
}
Package dashPackage
Pagination *struct{}
TagState []struct{}
}
// MaintnerClient is a subset of apipb.MaintnerServiceClient.
type MaintnerClient interface {
// GetDashboard is extracted from apipb.MaintnerServiceClient.
GetDashboard(ctx context.Context, in *apipb.DashboardRequest, opts ...grpc4.CallOption) (*apipb.DashboardResponse, error)
}
type Handler struct {
// Datastore is a client used for fetching build status. If nil, it uses in-memory storage of build status.
Datastore *datastore.Client
// Maintner is a client for Maintner, used for fetching lists of commits.
Maintner MaintnerClient
// memoryResults is an in-memory storage of CI results. Used in development and testing for datastore data.
memoryResults map[string][]string
}
func (d *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
dd := &data{
Builders: d.getBuilders(dashboard.Builders),
Commits: d.commits(r.Context()),
Package: dashPackage{Name: "Go"},
}
var buf bytes.Buffer
if err := templ.Execute(&buf, dd); err != nil {
log.Printf("handleDashboard: error rendering template: %v", err)
http.Error(rw, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
buf.WriteTo(rw)
}
func (d *Handler) commits(ctx context.Context) []*commit {
var commits []*commit
resp, err := d.Maintner.GetDashboard(ctx, &apipb.DashboardRequest{})
if err != nil {
log.Printf("handleDashboard: error fetching from maintner: %v", err)
return commits
}
for _, c := range resp.GetCommits() {
commits = append(commits, &commit{
Desc: c.Title,
Hash: c.Commit,
Time: time.Unix(c.CommitTimeSec, 0).Format("02 Jan 15:04"),
User: formatGitAuthor(c.AuthorName, c.AuthorEmail),
})
}
d.getResults(ctx, commits)
return commits
}
// getResults populates result data on commits, fetched from Datastore or in-memory storage.
func (d *Handler) getResults(ctx context.Context, commits []*commit) {
if d.Datastore == nil {
for _, c := range commits {
if result, ok := d.memoryResults[c.Hash]; ok {
c.ResultData = result
}
}
return
}
getDatastoreResults(ctx, d.Datastore, commits, "go")
}
func (d *Handler) getBuilders(conf map[string]*dashboard.BuildConfig) []*builder {
bm := make(map[string]builder)
for _, b := range conf {
if !b.BuildsRepoPostSubmit("go", "master", "master") {
continue
}
db := bm[b.GOOS()]
db.OS = b.GOOS()
db.Archs = append(db.Archs, &arch{
Arch: b.GOARCH(),
Name: b.Name,
Tag: strings.TrimPrefix(b.Name, fmt.Sprintf("%s-%s-", b.GOOS(), b.GOARCH())),
})
bm[b.GOOS()] = db
}
var builders builderSlice
for _, db := range bm {
db := db
sort.Sort(&db.Archs)
builders = append(builders, &db)
}
sort.Sort(builders)
return builders
}
type arch struct {
Arch string
Name string
Tag string
}
func (a arch) FirstClass() bool {
segs := strings.SplitN(a.Name, "-", 3)
if len(segs) < 2 {
return false
}
if fc, ok := firstClassPorts[strings.Join(segs[0:2], "-")]; ok {
return fc
}
return false
}
type archSlice []*arch
func (d archSlice) Len() int {
return len(d)
}
// Less sorts first-class ports first, then it sorts by name.
func (d archSlice) Less(i, j int) bool {
iFirst, jFirst := d[i].FirstClass(), d[j].FirstClass()
if iFirst && !jFirst {
return true
}
if !iFirst && jFirst {
return false
}
return d[i].Name < d[j].Name
}
func (d archSlice) Swap(i, j int) {
d[i], d[j] = d[j], d[i]
}
type builder struct {
Active bool
Archs archSlice
OS string
Unsupported bool
}
func (b *builder) FirstClass() bool {
for _, a := range b.Archs {
if a.FirstClass() {
return true
}
}
return false
}
func (b *builder) FirstClassArchs() archSlice {
var as archSlice
for _, a := range b.Archs {
if a.FirstClass() {
as = append(as, a)
}
}
return as
}
type builderSlice []*builder
func (d builderSlice) Len() int {
return len(d)
}
// Less sorts first-class ports first, then it sorts by name.
func (d builderSlice) Less(i, j int) bool {
iFirst, jFirst := d[i].FirstClass(), d[j].FirstClass()
if iFirst && !jFirst {
return true
}
if !iFirst && jFirst {
return false
}
return d[i].OS < d[j].OS
}
func (d builderSlice) Swap(i, j int) {
d[i], d[j] = d[j], d[i]
}
type dashPackage struct {
Name string
Path string
}
type commit struct {
Desc string
Hash string
ResultData []string
Time string
User string
}
// shortUser returns a shortened version of a user string.
func (c *commit) ShortUser() string {
user := c.User
if i, j := strings.Index(user, "<"), strings.Index(user, ">"); 0 <= i && i < j {
user = user[i+1 : j]
}
if i := strings.Index(user, "@"); i >= 0 {
return user[:i]
}
return user
}
func (c *commit) ResultForBuilder(builder string) result {
for _, rd := range c.ResultData {
segs := strings.Split(rd, "|")
if len(segs) < 4 {
continue
}
if segs[0] == builder {
return result{
OK: segs[1] == "true",
LogHash: segs[2],
}
}
}
return result{}
}
type result struct {
BuildingURL string
OK bool
LogHash string
}
// formatGitAuthor formats the git author name and email (as split by
// maintner) back into the unified string how they're stored in a git
// commit, so the shortUser func (used by the HTML template) can parse
// back out the email part's username later. Maybe we could plumb down
// the parsed proto into the template later.
func formatGitAuthor(name, email string) string {
name = strings.TrimSpace(name)
email = strings.TrimSpace(email)
if name != "" && email != "" {
return fmt.Sprintf("%s <%s>", name, email)
}
if name != "" {
return name
}
return "<" + email + ">"
}
var templ = template.Must(
template.New("dashboard.html").ParseFiles(
internal.FilePath("dashboard.html", "internal/dashboard", "cmd/coordinator/internal/dashboard"),
),
)
// A Commit describes an individual commit in a package.
//
// Each Commit entity is a descendant of its associated Package entity.
// In other words, all Commits with the same PackagePath belong to the same
// datastore entity group.
type Commit struct {
PackagePath string // (empty for main repo commits)
Hash string
// ResultData is the Data string of each build Result for this Commit.
// For non-Go commits, only the Results for the current Go tip, weekly,
// and release Tags are stored here. This is purely de-normalized data.
// The complete data set is stored in Result entities.
//
// Each string is formatted as builder|OK|LogHash|GoHash.
ResultData []string `datastore:",noindex"`
}

Просмотреть файл

@ -0,0 +1,218 @@
// Copyright 2020 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build go1.13
// +build linux
package dashboard
import (
"context"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"golang.org/x/build/dashboard"
"golang.org/x/build/maintner/maintnerd/apipb"
"golang.org/x/build/types"
grpc4 "grpc.go4.org"
)
type fakeMaintner struct {
resp *apipb.DashboardResponse
}
func (f *fakeMaintner) GetDashboard(ctx context.Context, in *apipb.DashboardRequest, opts ...grpc4.CallOption) (*apipb.DashboardResponse, error) {
return f.resp, nil
}
func TestHandlerServeHTTP(t *testing.T) {
fm := &fakeMaintner{
resp: &apipb.DashboardResponse{Commits: []*apipb.DashCommit{
{
Title: "x/build/cmd/coordinator: implement dashboard",
Commit: "752029e171d535b0dd4ff7bbad5ad0275a3969a8",
CommitTimeSec: 1257894000,
AuthorName: "Gopherbot",
AuthorEmail: "gopherbot@example.com",
},
}},
}
dh := &Handler{
Maintner: fm,
memoryResults: map[string][]string{
"752029e171d535b0dd4ff7bbad5ad0275a3969a8": {"linux-amd64-longtest|true|SomeLog|752029e171d535b0dd4ff7bbad5ad0275a3969a8"},
},
}
req := httptest.NewRequest("GET", "/dashboard", nil)
w := httptest.NewRecorder()
dh.ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
ioutil.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Errorf("resp.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
}
}
func TestHandlerCommits(t *testing.T) {
fm := &fakeMaintner{
resp: &apipb.DashboardResponse{Commits: []*apipb.DashCommit{
{
Title: "x/build/cmd/coordinator: implement dashboard",
Commit: "752029e171d535b0dd4ff7bbad5ad0275a3969a8",
CommitTimeSec: 1257894000,
AuthorName: "Gopherbot",
AuthorEmail: "gopherbot@example.com",
},
}},
}
dh := &Handler{
Maintner: fm,
memoryResults: map[string][]string{
"752029e171d535b0dd4ff7bbad5ad0275a3969a8": {"test-builder|true|SomeLog|752029e171d535b0dd4ff7bbad5ad0275a3969a8"},
},
}
want := []*commit{
{
Desc: "x/build/cmd/coordinator: implement dashboard",
Hash: "752029e171d535b0dd4ff7bbad5ad0275a3969a8",
Time: time.Unix(1257894000, 0).Format("02 Jan 15:04"),
User: "Gopherbot <gopherbot@example.com>",
ResultData: []string{"test-builder|true|SomeLog|752029e171d535b0dd4ff7bbad5ad0275a3969a8"},
},
}
got := dh.commits(context.Background())
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("dh.Commits() mismatch (-want +got):\n%s", diff)
}
}
func TestHandlerGetBuilders(t *testing.T) {
dh := &Handler{}
builders := map[string]*dashboard.BuildConfig{
"linux-amd64-testfile": {
Name: "linux-amd64-testfile",
HostType: "this-is-a-test-file",
Notes: "",
MinimumGoVersion: types.MajorMinor{},
},
"linux-386-testfile": {
Name: "linux-386-testfile",
HostType: "this-is-a-test-file",
Notes: "",
MinimumGoVersion: types.MajorMinor{},
},
"darwin-amd64-testfile": {
Name: "darwin-amd64-testfile",
HostType: "this-is-a-test-file",
Notes: "",
MinimumGoVersion: types.MajorMinor{},
},
"android-386-testfile": {
Name: "android-386-testfile",
HostType: "this-is-a-test-file",
Notes: "",
MinimumGoVersion: types.MajorMinor{},
},
}
want := []*builder{
{
OS: "darwin",
Archs: []*arch{
{
Arch: "amd64",
Name: "darwin-amd64-testfile",
Tag: "testfile",
},
},
},
{
OS: "linux",
Archs: []*arch{
{
Arch: "386",
Name: "linux-386-testfile",
Tag: "testfile",
},
{
Arch: "amd64",
Name: "linux-amd64-testfile",
Tag: "testfile",
},
},
},
{
OS: "android",
Archs: []*arch{
{
Arch: "386",
Name: "android-386-testfile",
Tag: "testfile",
},
},
},
}
got := dh.getBuilders(builders)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("dh.getBuilders() mismatch (-want +got):\n%s", diff)
}
}
func TestArchFirstClass(t *testing.T) {
cases := []struct {
name string
want bool
}{
{
name: "linux-amd64-longtest",
want: true,
},
{
name: "linux-buzz-longtest",
want: false,
},
{
name: "linux-amd64",
want: true,
},
{
name: "linux",
want: false,
},
}
for _, c := range cases {
a := &arch{Name: c.name}
if a.FirstClass() != c.want {
t.Errorf("%+v.FirstClass() = %v, wanted %v", a, a.FirstClass(), c.want)
}
}
}
func TestCommitResultForBuilder(t *testing.T) {
c := &commit{
Desc: "x/build/cmd/coordinator: implement dashboard",
Hash: "752029e171d535b0dd4ff7bbad5ad0275a3969a8",
Time: "10 Nov 18:00",
User: "Gopherbot <gopherbot@example.com>",
ResultData: []string{"test-builder|true|SomeLog|752029e171d535b0dd4ff7bbad5ad0275a3969a8"},
}
want := result{
OK: true,
LogHash: "SomeLog",
}
got := c.ResultForBuilder("test-builder")
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("c.ResultForBuilder(%q) mismatch (-want +got):\n%s", "test-builder", diff)
}
}

Просмотреть файл

@ -0,0 +1,20 @@
package internal
import (
"os"
"path/filepath"
)
// FilePath returns the path to the specified file. If the file is not found
// in the current directory, it will return a relative path for the prefix
// that the file exists at.
func FilePath(base string, prefixes ...string) string {
// First, attempt to find the file with no prefix.
prefixes = append([]string{""}, prefixes...)
for _, p := range prefixes {
if _, err := os.Stat(filepath.Join(p, base)); err == nil {
return filepath.Join(p, base)
}
}
return base
}

Просмотреть файл

@ -29,6 +29,7 @@ import (
"sync/atomic"
"time"
"golang.org/x/build/cmd/coordinator/internal"
"golang.org/x/build/dashboard"
"golang.org/x/build/internal/foreach"
)
@ -794,83 +795,19 @@ var statusTmpl = template.Must(template.New("status").Parse(`
</html>
`))
var styleCSS []byte
// loadStatic loads static resources into memroy for serving.
func loadStatic() error {
path := internal.FilePath("style.css", "cmd/coordinator")
css, err := ioutil.ReadFile(path)
if err != nil {
return fmt.Errorf("ioutil.ReadFile(%q): %w", path, err)
}
styleCSS = css
return nil
}
func handleStyleCSS(w http.ResponseWriter, r *http.Request) {
src := strings.NewReader(styleCSS)
http.ServeContent(w, r, "style.css", processStartTime, src)
http.ServeContent(w, r, "style.css", processStartTime, bytes.NewReader(styleCSS))
}
const styleCSS = `
body {
font-family: sans-serif;
color: #222;
padding: 10px;
margin: 0;
}
h1, h2, h1 > a, h2 > a, h1 > a:visited, h2 > a:visited {
color: #375EAB;
}
h1 { font-size: 24px; }
h2 { font-size: 20px; }
h1 > a, h2 > a {
display: none;
text-decoration: none;
}
h1:hover > a, h2:hover > a {
display: inline;
}
h1 > a:hover, h2 > a:hover {
text-decoration: underline;
}
pre {
font-family: monospace;
font-size: 9pt;
}
header {
margin: -10px -10px 0 -10px;
padding: 10px 10px;
background: #E0EBF5;
}
header a { color: #222; }
header h1 {
display: inline;
margin: 0;
padding-top: 5px;
}
header nav {
display: inline-block;
margin-left: 20px;
}
header nav a {
display: inline-block;
padding: 10px;
margin: 0;
margin-right: 5px;
color: white;
background: #375EAB;
text-decoration: none;
font-size: 16px;
border: 1px solid #375EAB;
border-radius: 5px;
}
table {
border-collapse: collapse;
font-size: 9pt;
}
table td, table th, table td, table th {
text-align: left;
vertical-align: top;
padding: 2px 6px;
}
table thead tr {
background: #fff !important;
}
`

161
cmd/coordinator/style.css Normal file
Просмотреть файл

@ -0,0 +1,161 @@
* {
box-sizing: border-box;
}
body {
color: #222;
font-family: sans-serif;
margin: 0;
padding: 10px;
}
h1,
h2,
h1 > a,
h2 > a,
h1 > a:visited,
h2 > a:visited {
color: #375eab;
}
h1 {
font-size: 24px;
}
h2 {
font-size: 20px;
}
h1 > a,
h2 > a {
display: none;
text-decoration: none;
}
h1:hover > a,
h2:hover > a {
display: inline;
}
h1 > a:hover,
h2 > a:hover {
text-decoration: underline;
}
pre {
font-family: monospace;
font-size: 9pt;
}
header {
background: #e0ebf5;
margin: -10px -10px 0 -10px;
padding: 10px 10px;
}
header a {
color: #222;
}
header h1 {
display: inline;
margin: 0;
padding-top: 5px;
}
header nav {
display: inline-block;
margin-left: 20px;
}
header nav a {
background: #375eab;
border: 1px solid #375eab;
border-radius: 5px;
color: white;
display: inline-block;
font-size: 16px;
margin: 0;
margin-right: 5px;
padding: 10px;
text-decoration: none;
}
table {
border-collapse: collapse;
font-size: 9pt;
}
table td,
table th,
table td,
table th {
padding: 2px 6px;
text-align: left;
vertical-align: top;
}
table thead tr {
background: #fff !important;
}
.Dashboard {
margin: 0;
padding: 0;
}
.Dashboard-topbar {
margin: 0;
padding: 1rem 0.625rem;
}
table.Build tbody tr:nth-child(even) {
background-color: #f4f4f4;
}
.ShortHash {
display: inline-block;
font-family: monospace;
overflow: hidden;
text-overflow: clip;
width: 3.2rem;
}
.Dashboard-packageName {
background: #e0ebf5;
padding: 0.125rem 0.3125rem;
}
.Dashboard-controls {
padding: 0.5rem;
}
.Build-scrollTable {
overflow-x: auto;
}
.Build-descriptionColumn,
.Build-osColumn {
border-right: 0.0625rem solid #ccc;
}
.Build-resultArch {
max-width: 2rem;
overflow: hidden;
text-overflow: clip;
whitespace: nowrap;
}
.Build-user {
font-size: 0.8125rem;
max-width: 3.4rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.Build-commitTime {
color: #4e4e4e;
font-size: 0.8125rem;
white-space: nowrap;
}
.Build-desc {
max-width: 8.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.Build-builderOSRow th {
font-size: 0.8125rem;
text-align: center;
}
.Build-builderArchRow th,
.Build-builderTagRow th {
font-size: 0.5625rem;
font-weight: normal;
text-align: center;
white-space: nowrap;
}