зеркало из https://github.com/golang/build.git
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:
Родитель
ef9e68dfbd
Коммит
236dbea5aa
|
@ -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> </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> </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}}> </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;
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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;
|
||||
}
|
Загрузка…
Ссылка в новой задаче