diff options
Diffstat (limited to 'misc/dashboard')
22 files changed, 2111 insertions, 754 deletions
diff --git a/misc/dashboard/app/app.yaml b/misc/dashboard/app/app.yaml new file mode 100644 index 000000000..685ca6e3d --- /dev/null +++ b/misc/dashboard/app/app.yaml @@ -0,0 +1,20 @@ +# Update with +# google_appengine/appcfg.py [-V test-build] update . +# +# Using -V test-build will run as test-build.golang.org. + +application: golang-org +version: build +runtime: go +api_version: 3 + +handlers: +- url: /static + static_dir: static +- url: /log/.+ + script: _go_app +- url: /(|commit|packages|result|tag|todo) + script: _go_app +- url: /(init|buildtest|key|_ah/queue/go/delay) + script: _go_app + login: admin diff --git a/misc/dashboard/app/build/build.go b/misc/dashboard/app/build/build.go new file mode 100644 index 000000000..175812a37 --- /dev/null +++ b/misc/dashboard/app/build/build.go @@ -0,0 +1,294 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package build + +import ( + "bytes" + "compress/gzip" + "crypto/sha1" + "fmt" + "io" + "io/ioutil" + "os" + "strings" + + "appengine" + "appengine/datastore" +) + +const maxDatastoreStringLen = 500 + +// A Package describes a package that is listed on the dashboard. +type Package struct { + Name string + Path string // (empty for the main Go tree) + NextNum int // Num of the next head Commit +} + +func (p *Package) String() string { + return fmt.Sprintf("%s: %q", p.Path, p.Name) +} + +func (p *Package) Key(c appengine.Context) *datastore.Key { + key := p.Path + if key == "" { + key = "go" + } + return datastore.NewKey(c, "Package", key, 0, nil) +} + +// LastCommit returns the most recent Commit for this Package. +func (p *Package) LastCommit(c appengine.Context) (*Commit, os.Error) { + var commits []*Commit + _, err := datastore.NewQuery("Commit"). + Ancestor(p.Key(c)). + Order("-Time"). + Limit(1). + GetAll(c, &commits) + if err != nil { + return nil, err + } + if len(commits) != 1 { + return nil, datastore.ErrNoSuchEntity + } + return commits[0], nil +} + +// GetPackage fetches a Package by path from the datastore. +func GetPackage(c appengine.Context, path string) (*Package, os.Error) { + p := &Package{Path: path} + err := datastore.Get(c, p.Key(c), p) + if err == datastore.ErrNoSuchEntity { + return nil, fmt.Errorf("package %q not found", path) + } + return p, err +} + +// 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 Go commits) + Hash string + ParentHash string + Num int // Internal monotonic counter unique to this package. + + User string + Desc string `datastore:",noindex"` + Time datastore.Time + + // ResultData is the Data string of each build Result for this Commit. + // For non-Go commits, only the Results for the current Go tip, weekly, + // and release Tags are stored here. This is purely de-normalized data. + // The complete data set is stored in Result entities. + ResultData []string `datastore:",noindex"` + + FailNotificationSent bool +} + +func (com *Commit) Key(c appengine.Context) *datastore.Key { + if com.Hash == "" { + panic("tried Key on Commit with empty Hash") + } + p := Package{Path: com.PackagePath} + key := com.PackagePath + "|" + com.Hash + return datastore.NewKey(c, "Commit", key, 0, p.Key(c)) +} + +func (c *Commit) Valid() os.Error { + if !validHash(c.Hash) { + return os.NewError("invalid Hash") + } + if c.ParentHash != "" && !validHash(c.ParentHash) { // empty is OK + return os.NewError("invalid ParentHash") + } + return nil +} + +// AddResult adds the denormalized Reuslt data to the Commit's Result field. +// It must be called from inside a datastore transaction. +func (com *Commit) AddResult(c appengine.Context, r *Result) os.Error { + if err := datastore.Get(c, com.Key(c), com); err != nil { + return fmt.Errorf("getting Commit: %v", err) + } + com.ResultData = append(com.ResultData, r.Data()) + if _, err := datastore.Put(c, com.Key(c), com); err != nil { + return fmt.Errorf("putting Commit: %v", err) + } + return nil +} + +// Result returns the build Result for this Commit for the given builder/goHash. +func (c *Commit) Result(builder, goHash string) *Result { + for _, r := range c.ResultData { + p := strings.SplitN(r, "|", 4) + if len(p) != 4 || p[0] != builder || p[3] != goHash { + continue + } + return partsToHash(c, p) + } + return nil +} + +// Results returns the build Results for this Commit for the given goHash. +func (c *Commit) Results(goHash string) (results []*Result) { + for _, r := range c.ResultData { + p := strings.SplitN(r, "|", 4) + if len(p) != 4 || p[3] != goHash { + continue + } + results = append(results, partsToHash(c, p)) + } + return +} + +// partsToHash converts a Commit and ResultData substrings to a Result. +func partsToHash(c *Commit, p []string) *Result { + return &Result{ + Builder: p[0], + Hash: c.Hash, + PackagePath: c.PackagePath, + GoHash: p[3], + OK: p[1] == "true", + LogHash: p[2], + } +} + +// OK returns the Commit's build state for a specific builder and goHash. +func (c *Commit) OK(builder, goHash string) (ok, present bool) { + r := c.Result(builder, goHash) + if r == nil { + return false, false + } + return r.OK, true +} + +// A Result describes a build result for a Commit on an OS/architecture. +// +// Each Result entity is a descendant of its associated Commit entity. +type Result struct { + Builder string // "arch-os[-note]" + Hash string + PackagePath string // (empty for Go commits) + + // The Go Commit this was built against (empty for Go commits). + GoHash string + + OK bool + Log string `datastore:"-"` // for JSON unmarshaling only + LogHash string `datastore:",noindex"` // Key to the Log record. + + RunTime int64 // time to build+test in nanoseconds +} + +func (r *Result) Key(c appengine.Context) *datastore.Key { + p := Package{Path: r.PackagePath} + key := r.Builder + "|" + r.PackagePath + "|" + r.Hash + "|" + r.GoHash + return datastore.NewKey(c, "Result", key, 0, p.Key(c)) +} + +func (r *Result) Valid() os.Error { + if !validHash(r.Hash) { + return os.NewError("invalid Hash") + } + if r.PackagePath != "" && !validHash(r.GoHash) { + return os.NewError("invalid GoHash") + } + return nil +} + +// Data returns the Result in string format +// to be stored in Commit's ResultData field. +func (r *Result) Data() string { + return fmt.Sprintf("%v|%v|%v|%v", r.Builder, r.OK, r.LogHash, r.GoHash) +} + +// A Log is a gzip-compressed log file stored under the SHA1 hash of the +// uncompressed log text. +type Log struct { + CompressedLog []byte +} + +func (l *Log) Text() ([]byte, os.Error) { + d, err := gzip.NewReader(bytes.NewBuffer(l.CompressedLog)) + if err != nil { + return nil, fmt.Errorf("reading log data: %v", err) + } + b, err := ioutil.ReadAll(d) + if err != nil { + return nil, fmt.Errorf("reading log data: %v", err) + } + return b, nil +} + +func PutLog(c appengine.Context, text string) (hash string, err os.Error) { + h := sha1.New() + io.WriteString(h, text) + b := new(bytes.Buffer) + z, _ := gzip.NewWriterLevel(b, gzip.BestCompression) + io.WriteString(z, text) + z.Close() + hash = fmt.Sprintf("%x", h.Sum()) + key := datastore.NewKey(c, "Log", hash, 0, nil) + _, err = datastore.Put(c, key, &Log{b.Bytes()}) + return +} + +// A Tag is used to keep track of the most recent Go weekly and release tags. +// Typically there will be one Tag entity for each kind of hg tag. +type Tag struct { + Kind string // "weekly", "release", or "tip" + Name string // the tag itself (for example: "release.r60") + Hash string +} + +func (t *Tag) Key(c appengine.Context) *datastore.Key { + p := &Package{} + return datastore.NewKey(c, "Tag", t.Kind, 0, p.Key(c)) +} + +func (t *Tag) Valid() os.Error { + if t.Kind != "weekly" && t.Kind != "release" && t.Kind != "tip" { + return os.NewError("invalid Kind") + } + if !validHash(t.Hash) { + return os.NewError("invalid Hash") + } + return nil +} + +// GetTag fetches a Tag by name from the datastore. +func GetTag(c appengine.Context, tag string) (*Tag, os.Error) { + t := &Tag{Kind: tag} + if err := datastore.Get(c, t.Key(c), t); err != nil { + if err == datastore.ErrNoSuchEntity { + return nil, os.NewError("tag not found: " + tag) + } + return nil, err + } + if err := t.Valid(); err != nil { + return nil, err + } + return t, nil +} + +// Packages returns all non-Go packages. +func Packages(c appengine.Context) ([]*Package, os.Error) { + var pkgs []*Package + for t := datastore.NewQuery("Package").Run(c); ; { + pkg := new(Package) + if _, err := t.Next(pkg); err == datastore.Done { + break + } else if err != nil { + return nil, err + } + if pkg.Path != "" { + pkgs = append(pkgs, pkg) + } + } + return pkgs, nil +} diff --git a/misc/dashboard/app/build/handler.go b/misc/dashboard/app/build/handler.go new file mode 100644 index 000000000..b3e62ad46 --- /dev/null +++ b/misc/dashboard/app/build/handler.go @@ -0,0 +1,428 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package build + +import ( + "crypto/hmac" + "fmt" + "http" + "json" + "os" + + "appengine" + "appengine/datastore" + "cache" +) + +const commitsPerPage = 30 + +// defaultPackages specifies the Package records to be created by initHandler. +var defaultPackages = []*Package{ + &Package{Name: "Go"}, +} + +// commitHandler retrieves commit data or records a new commit. +// +// For GET requests it returns a Commit value for the specified +// packagePath and hash. +// +// For POST requests it reads a JSON-encoded Commit value from the request +// body and creates a new Commit entity. It also updates the "tip" Tag for +// each new commit at tip. +// +// This handler is used by a gobuilder process in -commit mode. +func commitHandler(r *http.Request) (interface{}, os.Error) { + c := appengine.NewContext(r) + com := new(Commit) + + if r.Method == "GET" { + com.PackagePath = r.FormValue("packagePath") + com.Hash = r.FormValue("hash") + if err := datastore.Get(c, com.Key(c), com); err != nil { + return nil, fmt.Errorf("getting Commit: %v", err) + } + return com, nil + } + if r.Method != "POST" { + return nil, errBadMethod(r.Method) + } + + // POST request + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(com); err != nil { + return nil, fmt.Errorf("decoding Body: %v", err) + } + if len(com.Desc) > maxDatastoreStringLen { + com.Desc = com.Desc[:maxDatastoreStringLen] + } + if err := com.Valid(); err != nil { + return nil, fmt.Errorf("validating Commit: %v", err) + } + defer cache.Tick(c) + tx := func(c appengine.Context) os.Error { + return addCommit(c, com) + } + return nil, datastore.RunInTransaction(c, tx, nil) +} + +// addCommit adds the Commit entity to the datastore and updates the tip Tag. +// It must be run inside a datastore transaction. +func addCommit(c appengine.Context, com *Commit) os.Error { + var tc Commit // temp value so we don't clobber com + err := datastore.Get(c, com.Key(c), &tc) + if err != datastore.ErrNoSuchEntity { + // if this commit is already in the datastore, do nothing + if err == nil { + return nil + } + return fmt.Errorf("getting Commit: %v", err) + } + // get the next commit number + p, err := GetPackage(c, com.PackagePath) + if err != nil { + return fmt.Errorf("GetPackage: %v", err) + } + com.Num = p.NextNum + p.NextNum++ + if _, err := datastore.Put(c, p.Key(c), p); err != nil { + return fmt.Errorf("putting Package: %v", err) + } + // if this isn't the first Commit test the parent commit exists + if com.Num > 0 { + n, err := datastore.NewQuery("Commit"). + Filter("Hash =", com.ParentHash). + Ancestor(p.Key(c)). + Count(c) + if err != nil { + return fmt.Errorf("testing for parent Commit: %v", err) + } + if n == 0 { + return os.NewError("parent commit not found") + } + } + // update the tip Tag if this is the Go repo + if p.Path == "" { + t := &Tag{Kind: "tip", Hash: com.Hash} + if _, err = datastore.Put(c, t.Key(c), t); err != nil { + return fmt.Errorf("putting Tag: %v", err) + } + } + // put the Commit + if _, err = datastore.Put(c, com.Key(c), com); err != nil { + return fmt.Errorf("putting Commit: %v", err) + } + return nil +} + +// tagHandler records a new tag. It reads a JSON-encoded Tag value from the +// request body and updates the Tag entity for the Kind of tag provided. +// +// This handler is used by a gobuilder process in -commit mode. +func tagHandler(r *http.Request) (interface{}, os.Error) { + if r.Method != "POST" { + return nil, errBadMethod(r.Method) + } + + t := new(Tag) + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(t); err != nil { + return nil, err + } + if err := t.Valid(); err != nil { + return nil, err + } + c := appengine.NewContext(r) + defer cache.Tick(c) + _, err := datastore.Put(c, t.Key(c), t) + return nil, err +} + +// Todo is a todoHandler response. +type Todo struct { + Kind string // "build-go-commit" or "build-package" + Data interface{} +} + +// todoHandler returns the next action to be performed by a builder. +// It expects "builder" and "kind" query parameters and returns a *Todo value. +// Multiple "kind" parameters may be specified. +func todoHandler(r *http.Request) (interface{}, os.Error) { + c := appengine.NewContext(r) + now := cache.Now(c) + key := "build-todo-" + r.Form.Encode() + var todo *Todo + if cache.Get(r, now, key, &todo) { + return todo, nil + } + var err os.Error + builder := r.FormValue("builder") + for _, kind := range r.Form["kind"] { + var data interface{} + switch kind { + case "build-go-commit": + data, err = buildTodo(c, builder, "", "") + case "build-package": + packagePath := r.FormValue("packagePath") + goHash := r.FormValue("goHash") + data, err = buildTodo(c, builder, packagePath, goHash) + } + if data != nil || err != nil { + todo = &Todo{Kind: kind, Data: data} + break + } + } + if err == nil { + cache.Set(r, now, key, todo) + } + return todo, err +} + +// buildTodo returns the next Commit to be built (or nil if none available). +// +// If packagePath and goHash are empty, it scans the first 20 Go Commits in +// Num-descending order and returns the first one it finds that doesn't have a +// Result for this builder. +// +// If provided with non-empty packagePath and goHash args, it scans the first +// 20 Commits in Num-descending order for the specified packagePath and +// returns the first that doesn't have a Result for this builder and goHash. +func buildTodo(c appengine.Context, builder, packagePath, goHash string) (interface{}, os.Error) { + p, err := GetPackage(c, packagePath) + if err != nil { + return nil, err + } + + t := datastore.NewQuery("Commit"). + Ancestor(p.Key(c)). + Limit(commitsPerPage). + Order("-Num"). + Run(c) + for { + com := new(Commit) + if _, err := t.Next(com); err != nil { + if err == datastore.Done { + err = nil + } + return nil, err + } + if com.Result(builder, goHash) == nil { + return com, nil + } + } + panic("unreachable") +} + +// packagesHandler returns a list of the non-Go Packages monitored +// by the dashboard. +func packagesHandler(r *http.Request) (interface{}, os.Error) { + c := appengine.NewContext(r) + now := cache.Now(c) + const key = "build-packages" + var p []*Package + if cache.Get(r, now, key, &p) { + return p, nil + } + p, err := Packages(c) + if err != nil { + return nil, err + } + cache.Set(r, now, key, p) + return p, nil +} + +// resultHandler records a build result. +// It reads a JSON-encoded Result value from the request body, +// creates a new Result entity, and updates the relevant Commit entity. +// If the Log field is not empty, resultHandler creates a new Log entity +// and updates the LogHash field before putting the Commit entity. +func resultHandler(r *http.Request) (interface{}, os.Error) { + if r.Method != "POST" { + return nil, errBadMethod(r.Method) + } + + c := appengine.NewContext(r) + res := new(Result) + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(res); err != nil { + return nil, fmt.Errorf("decoding Body: %v", err) + } + if err := res.Valid(); err != nil { + return nil, fmt.Errorf("validating Result: %v", err) + } + defer cache.Tick(c) + // store the Log text if supplied + if len(res.Log) > 0 { + hash, err := PutLog(c, res.Log) + if err != nil { + return nil, fmt.Errorf("putting Log: %v", err) + } + res.LogHash = hash + } + tx := func(c appengine.Context) os.Error { + // check Package exists + if _, err := GetPackage(c, res.PackagePath); err != nil { + return fmt.Errorf("GetPackage: %v", err) + } + // put Result + if _, err := datastore.Put(c, res.Key(c), res); err != nil { + return fmt.Errorf("putting Result: %v", err) + } + // add Result to Commit + com := &Commit{PackagePath: res.PackagePath, Hash: res.Hash} + if err := com.AddResult(c, res); err != nil { + return fmt.Errorf("AddResult: %v", err) + } + // Send build failure notifications, if necessary. + // Note this must run after the call AddResult, which + // populates the Commit's ResultData field. + return notifyOnFailure(c, com, res.Builder) + } + return nil, datastore.RunInTransaction(c, tx, nil) +} + +// logHandler displays log text for a given hash. +// It handles paths like "/log/hash". +func logHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-type", "text/plain") + c := appengine.NewContext(r) + hash := r.URL.Path[len("/log/"):] + key := datastore.NewKey(c, "Log", hash, 0, nil) + l := new(Log) + if err := datastore.Get(c, key, l); err != nil { + logErr(w, r, err) + return + } + b, err := l.Text() + if err != nil { + logErr(w, r, err) + return + } + w.Write(b) +} + +type dashHandler func(*http.Request) (interface{}, os.Error) + +type dashResponse struct { + Response interface{} + Error string +} + +// errBadMethod is returned by a dashHandler when +// the request has an unsuitable method. +type errBadMethod string + +func (e errBadMethod) String() string { + return "bad method: " + string(e) +} + +// AuthHandler wraps a http.HandlerFunc with a handler that validates the +// supplied key and builder query parameters. +func AuthHandler(h dashHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + c := appengine.NewContext(r) + + // Put the URL Query values into r.Form to avoid parsing the + // request body when calling r.FormValue. + r.Form = r.URL.Query() + + var err os.Error + var resp interface{} + + // Validate key query parameter for POST requests only. + key := r.FormValue("key") + builder := r.FormValue("builder") + if r.Method == "POST" && !validKey(c, key, builder) { + err = os.NewError("invalid key: " + key) + } + + // Call the original HandlerFunc and return the response. + if err == nil { + resp, err = h(r) + } + + // Write JSON response. + dashResp := &dashResponse{Response: resp} + if err != nil { + c.Errorf("%v", err) + dashResp.Error = err.String() + } + w.Header().Set("Content-Type", "application/json") + if err = json.NewEncoder(w).Encode(dashResp); err != nil { + c.Criticalf("encoding response: %v", err) + } + } +} + +func initHandler(w http.ResponseWriter, r *http.Request) { + // TODO(adg): devise a better way of bootstrapping new packages + c := appengine.NewContext(r) + defer cache.Tick(c) + for _, p := range defaultPackages { + if err := datastore.Get(c, p.Key(c), new(Package)); err == nil { + continue + } else if err != datastore.ErrNoSuchEntity { + logErr(w, r, err) + return + } + if _, err := datastore.Put(c, p.Key(c), p); err != nil { + logErr(w, r, err) + return + } + } + fmt.Fprint(w, "OK") +} + +func keyHandler(w http.ResponseWriter, r *http.Request) { + builder := r.FormValue("builder") + if builder == "" { + logErr(w, r, os.NewError("must supply builder in query string")) + return + } + c := appengine.NewContext(r) + fmt.Fprint(w, builderKey(c, builder)) +} + +func init() { + // admin handlers + http.HandleFunc("/init", initHandler) + http.HandleFunc("/key", keyHandler) + + // authenticated handlers + http.HandleFunc("/commit", AuthHandler(commitHandler)) + http.HandleFunc("/packages", AuthHandler(packagesHandler)) + http.HandleFunc("/result", AuthHandler(resultHandler)) + http.HandleFunc("/tag", AuthHandler(tagHandler)) + http.HandleFunc("/todo", AuthHandler(todoHandler)) + + // public handlers + http.HandleFunc("/log/", logHandler) +} + +func validHash(hash string) bool { + // TODO(adg): correctly validate a hash + return hash != "" +} + +func validKey(c appengine.Context, key, builder string) bool { + if appengine.IsDevAppServer() { + return true + } + if key == secretKey(c) { + return true + } + return key == builderKey(c, builder) +} + +func builderKey(c appengine.Context, builder string) string { + h := hmac.NewMD5([]byte(secretKey(c))) + h.Write([]byte(builder)) + return fmt.Sprintf("%x", h.Sum()) +} + +func logErr(w http.ResponseWriter, r *http.Request, err os.Error) { + appengine.NewContext(r).Errorf("Error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, "Error: ", err) +} diff --git a/misc/dashboard/app/build/key.go b/misc/dashboard/app/build/key.go new file mode 100644 index 000000000..5306c3b6b --- /dev/null +++ b/misc/dashboard/app/build/key.go @@ -0,0 +1,62 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package build + +import ( + "sync" + + "appengine" + "appengine/datastore" +) + +var theKey struct { + sync.RWMutex + BuilderKey +} + +type BuilderKey struct { + Secret string +} + +func (k *BuilderKey) Key(c appengine.Context) *datastore.Key { + return datastore.NewKey(c, "BuilderKey", "root", 0, nil) +} + +func secretKey(c appengine.Context) string { + // check with rlock + theKey.RLock() + k := theKey.Secret + theKey.RUnlock() + if k != "" { + return k + } + + // prepare to fill; check with lock and keep lock + theKey.Lock() + defer theKey.Unlock() + if theKey.Secret != "" { + return theKey.Secret + } + + // fill + if err := datastore.Get(c, theKey.Key(c), &theKey.BuilderKey); err != nil { + if err == datastore.ErrNoSuchEntity { + // If the key is not stored in datastore, write it. + // This only happens at the beginning of a new deployment. + // The code is left here for SDK use and in case a fresh + // deployment is ever needed. "gophers rule" is not the + // real key. + if !appengine.IsDevAppServer() { + panic("lost key from datastore") + } + theKey.Secret = "gophers rule" + datastore.Put(c, theKey.Key(c), &theKey.BuilderKey) + return theKey.Secret + } + panic("cannot load builder key: " + err.String()) + } + + return theKey.Secret +} diff --git a/misc/dashboard/app/build/notify.go b/misc/dashboard/app/build/notify.go new file mode 100644 index 000000000..826132be2 --- /dev/null +++ b/misc/dashboard/app/build/notify.go @@ -0,0 +1,150 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package build + +import ( + "appengine" + "appengine/datastore" + "appengine/delay" + "appengine/mail" + "bytes" + "fmt" + "gob" + "os" + "template" +) + +const ( + mailFrom = "builder@golang.org" // use this for sending any mail + failMailTo = "golang-dev@googlegroups.com" + domain = "build.golang.org" +) + +// notifyOnFailure checks whether the supplied Commit or the subsequent +// Commit (if present) breaks the build for this builder. +// If either of those commits break the build an email notification is sent +// from a delayed task. (We use a task because this way the mail won't be +// sent if the enclosing datastore transaction fails.) +// +// This must be run in a datastore transaction, and the provided *Commit must +// have been retrieved from the datastore within that transaction. +func notifyOnFailure(c appengine.Context, com *Commit, builder string) os.Error { + // TODO(adg): implement notifications for packages + if com.PackagePath != "" { + return nil + } + + p := &Package{Path: com.PackagePath} + var broken *Commit + ok, present := com.OK(builder, "") + if !present { + return fmt.Errorf("no result for %s/%s", com.Hash, builder) + } + q := datastore.NewQuery("Commit").Ancestor(p.Key(c)) + if ok { + // This commit is OK. Notify if next Commit is broken. + next := new(Commit) + q.Filter("ParentHash=", com.Hash) + if err := firstMatch(c, q, next); err != nil { + if err == datastore.ErrNoSuchEntity { + // OK at tip, no notification necessary. + return nil + } + return err + } + if ok, present := next.OK(builder, ""); present && !ok { + broken = next + } + } else { + // This commit is broken. Notify if the previous Commit is OK. + prev := new(Commit) + q.Filter("Hash=", com.ParentHash) + if err := firstMatch(c, q, prev); err != nil { + if err == datastore.ErrNoSuchEntity { + // No previous result, let the backfill of + // this result trigger the notification. + return nil + } + return err + } + if ok, present := prev.OK(builder, ""); present && ok { + broken = com + } + } + var err os.Error + if broken != nil && !broken.FailNotificationSent { + c.Infof("%s is broken commit; notifying", broken.Hash) + sendFailMailLater.Call(c, broken, builder) // add task to queue + broken.FailNotificationSent = true + _, err = datastore.Put(c, broken.Key(c), broken) + } + return err +} + +// firstMatch executes the query q and loads the first entity into v. +func firstMatch(c appengine.Context, q *datastore.Query, v interface{}) os.Error { + t := q.Limit(1).Run(c) + _, err := t.Next(v) + if err == datastore.Done { + err = datastore.ErrNoSuchEntity + } + return err +} + +var ( + sendFailMailLater = delay.Func("sendFailMail", sendFailMail) + sendFailMailTmpl = template.Must( + template.New("notify").Funcs(tmplFuncs).ParseFile("build/notify.txt"), + ) +) + +func init() { + gob.Register(&Commit{}) // for delay +} + +// sendFailMail sends a mail notification that the build failed on the +// provided commit and builder. +func sendFailMail(c appengine.Context, com *Commit, builder string) { + // TODO(adg): handle packages + + // get Result + r := com.Result(builder, "") + if r == nil { + c.Errorf("finding result for %q: %+v", builder, com) + return + } + + // get Log + k := datastore.NewKey(c, "Log", r.LogHash, 0, nil) + l := new(Log) + if err := datastore.Get(c, k, l); err != nil { + c.Errorf("finding Log record %v: %v", r.LogHash, err) + return + } + + // prepare mail message + var body bytes.Buffer + err := sendFailMailTmpl.Execute(&body, map[string]interface{}{ + "Builder": builder, "Commit": com, "Result": r, "Log": l, + "Hostname": domain, + }) + if err != nil { + c.Errorf("rendering mail template: %v", err) + return + } + subject := fmt.Sprintf("%s broken by %s", builder, shortDesc(com.Desc)) + msg := &mail.Message{ + Sender: mailFrom, + To: []string{failMailTo}, + ReplyTo: failMailTo, + Subject: subject, + Body: body.String(), + } + + // send mail + if err := mail.Send(c, msg); err != nil { + c.Errorf("sending mail: %v", err) + } +} diff --git a/misc/dashboard/app/build/notify.txt b/misc/dashboard/app/build/notify.txt new file mode 100644 index 000000000..6c9006703 --- /dev/null +++ b/misc/dashboard/app/build/notify.txt @@ -0,0 +1,9 @@ +Change {{shortHash .Commit.Hash}} broke the {{.Builder}} build: +http://{{.Hostname}}/log/{{.Result.LogHash}} + +{{.Commit.Desc}} + +http://code.google.com/p/go/source/detail?r={{shortHash .Commit.Hash}} + +$ tail -200 < log +{{printf "%s" .Log.Text | tail 200}} diff --git a/misc/dashboard/app/build/test.go b/misc/dashboard/app/build/test.go new file mode 100644 index 000000000..a923969bc --- /dev/null +++ b/misc/dashboard/app/build/test.go @@ -0,0 +1,251 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package build + +// TODO(adg): test authentication + +import ( + "appengine" + "appengine/datastore" + "bytes" + "fmt" + "http" + "http/httptest" + "io" + "json" + "os" + "strings" + "time" + "url" +) + +func init() { + http.HandleFunc("/buildtest", testHandler) +} + +var testEntityKinds = []string{ + "Package", + "Commit", + "Result", + "Log", +} + +const testPkg = "code.google.com/p/go.test" + +var testPackage = &Package{Name: "Test", Path: testPkg} + +var testPackages = []*Package{ + &Package{Name: "Go", Path: ""}, + testPackage, +} + +var tCommitTime = time.Seconds() - 60*60*24*7 + +func tCommit(hash, parentHash string) *Commit { + tCommitTime += 60 * 60 * 12 // each commit should have a different time + return &Commit{ + Hash: hash, + ParentHash: parentHash, + Time: datastore.Time(tCommitTime * 1e6), + User: "adg", + Desc: "change description", + } +} + +var testRequests = []struct { + path string + vals url.Values + req interface{} + res interface{} +}{ + // Packages + {"/packages", nil, nil, []*Package{testPackage}}, + + // Go repo + {"/commit", nil, tCommit("0001", "0000"), nil}, + {"/commit", nil, tCommit("0002", "0001"), nil}, + {"/commit", nil, tCommit("0003", "0002"), nil}, + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-386"}}, nil, &Todo{Kind: "build-go-commit", Data: &Commit{Hash: "0003"}}}, + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-amd64"}}, nil, &Todo{Kind: "build-go-commit", Data: &Commit{Hash: "0003"}}}, + {"/result", nil, &Result{Builder: "linux-386", Hash: "0001", OK: true}, nil}, + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-386"}}, nil, &Todo{Kind: "build-go-commit", Data: &Commit{Hash: "0003"}}}, + {"/result", nil, &Result{Builder: "linux-386", Hash: "0002", OK: true}, nil}, + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-386"}}, nil, &Todo{Kind: "build-go-commit", Data: &Commit{Hash: "0003"}}}, + + // multiple builders + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-amd64"}}, nil, &Todo{Kind: "build-go-commit", Data: &Commit{Hash: "0003"}}}, + {"/result", nil, &Result{Builder: "linux-amd64", Hash: "0003", OK: true}, nil}, + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-386"}}, nil, &Todo{Kind: "build-go-commit", Data: &Commit{Hash: "0003"}}}, + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-amd64"}}, nil, &Todo{Kind: "build-go-commit", Data: &Commit{Hash: "0002"}}}, + + // branches + {"/commit", nil, tCommit("0004", "0003"), nil}, + {"/commit", nil, tCommit("0005", "0002"), nil}, + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-386"}}, nil, &Todo{Kind: "build-go-commit", Data: &Commit{Hash: "0005"}}}, + {"/result", nil, &Result{Builder: "linux-386", Hash: "0005", OK: true}, nil}, + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-386"}}, nil, &Todo{Kind: "build-go-commit", Data: &Commit{Hash: "0004"}}}, + {"/result", nil, &Result{Builder: "linux-386", Hash: "0004", OK: true}, nil}, + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-386"}}, nil, &Todo{Kind: "build-go-commit", Data: &Commit{Hash: "0003"}}}, + + // logs + {"/result", nil, &Result{Builder: "linux-386", Hash: "0003", OK: false, Log: "test"}, nil}, + {"/log/a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", nil, nil, "test"}, + {"/todo", url.Values{"kind": {"build-go-commit"}, "builder": {"linux-386"}}, nil, nil}, + + // repeat failure (shouldn't re-send mail) + {"/result", nil, &Result{Builder: "linux-386", Hash: "0003", OK: false, Log: "test"}, nil}, + + // non-Go repos + {"/commit", nil, &Commit{PackagePath: testPkg, Hash: "1001", ParentHash: "1000"}, nil}, + {"/commit", nil, &Commit{PackagePath: testPkg, Hash: "1002", ParentHash: "1001"}, nil}, + {"/commit", nil, &Commit{PackagePath: testPkg, Hash: "1003", ParentHash: "1002"}, nil}, + {"/todo", url.Values{"kind": {"build-package"}, "builder": {"linux-386"}, "packagePath": {testPkg}, "goHash": {"0001"}}, nil, &Todo{Kind: "build-package", Data: &Commit{Hash: "1003"}}}, + {"/result", nil, &Result{PackagePath: testPkg, Builder: "linux-386", Hash: "1003", GoHash: "0001", OK: true}, nil}, + {"/todo", url.Values{"kind": {"build-package"}, "builder": {"linux-386"}, "packagePath": {testPkg}, "goHash": {"0001"}}, nil, &Todo{Kind: "build-package", Data: &Commit{Hash: "1002"}}}, + {"/result", nil, &Result{PackagePath: testPkg, Builder: "linux-386", Hash: "1002", GoHash: "0001", OK: true}, nil}, + {"/todo", url.Values{"kind": {"build-package"}, "builder": {"linux-386"}, "packagePath": {testPkg}, "goHash": {"0001"}}, nil, &Todo{Kind: "build-package", Data: &Commit{Hash: "1001"}}}, + {"/result", nil, &Result{PackagePath: testPkg, Builder: "linux-386", Hash: "1001", GoHash: "0001", OK: true}, nil}, + {"/todo", url.Values{"kind": {"build-package"}, "builder": {"linux-386"}, "packagePath": {testPkg}, "goHash": {"0001"}}, nil, nil}, + {"/todo", url.Values{"kind": {"build-package"}, "builder": {"linux-386"}, "packagePath": {testPkg}, "goHash": {"0002"}}, nil, &Todo{Kind: "build-package", Data: &Commit{Hash: "1003"}}}, + {"/result", nil, &Result{PackagePath: testPkg, Builder: "linux-386", Hash: "1001", GoHash: "0005", OK: false, Log: "boo"}, nil}, +} + +func testHandler(w http.ResponseWriter, r *http.Request) { + if !appengine.IsDevAppServer() { + fmt.Fprint(w, "These tests must be run under the dev_appserver.") + return + } + c := appengine.NewContext(r) + if err := nukeEntities(c, testEntityKinds); err != nil { + logErr(w, r, err) + return + } + if r.FormValue("nukeonly") != "" { + fmt.Fprint(w, "OK") + return + } + + for _, p := range testPackages { + if _, err := datastore.Put(c, p.Key(c), p); err != nil { + logErr(w, r, err) + return + } + } + + for i, t := range testRequests { + c.Infof("running test %d %s", i, t.path) + errorf := func(format string, args ...interface{}) { + fmt.Fprintf(w, "%d %s: ", i, t.path) + fmt.Fprintf(w, format, args...) + fmt.Fprintln(w) + } + var body io.ReadWriter + if t.req != nil { + body = new(bytes.Buffer) + json.NewEncoder(body).Encode(t.req) + } + url := "http://" + domain + t.path + if t.vals != nil { + url += "?" + t.vals.Encode() + } + req, err := http.NewRequest("POST", url, body) + if err != nil { + logErr(w, r, err) + return + } + if t.req != nil { + req.Method = "POST" + } + req.Header = r.Header + rec := httptest.NewRecorder() + + // Make the request + http.DefaultServeMux.ServeHTTP(rec, req) + + if rec.Code != 0 && rec.Code != 200 { + errorf(rec.Body.String()) + return + } + resp := new(dashResponse) + + // If we're expecting a *Todo value, + // prime the Response field with a Todo and a Commit inside it. + if _, ok := t.res.(*Todo); ok { + resp.Response = &Todo{Data: &Commit{}} + } + + if strings.HasPrefix(t.path, "/log/") { + resp.Response = rec.Body.String() + } else { + err := json.NewDecoder(rec.Body).Decode(resp) + if err != nil { + errorf("decoding response: %v", err) + return + } + } + if e, ok := t.res.(string); ok { + g, ok := resp.Response.(string) + if !ok { + errorf("Response not string: %T", resp.Response) + return + } + if g != e { + errorf("response mismatch: got %q want %q", g, e) + return + } + } + if e, ok := t.res.(*Todo); ok { + g, ok := resp.Response.(*Todo) + if !ok { + errorf("Response not *Todo: %T", resp.Response) + return + } + if e.Data == nil && g.Data != nil { + errorf("Response.Data should be nil, got: %v", g.Data) + return + } + if g.Data == nil { + errorf("Response.Data is nil, want: %v", e.Data) + return + } + gd, ok := g.Data.(*Commit) + if !ok { + errorf("Response.Data not *Commit: %T", g.Data) + return + } + if e.Data.(*Commit).Hash != gd.Hash { + errorf("hashes don't match: got %q, want %q", g, e) + return + } + } + if t.res == nil && resp.Response != nil { + errorf("response mismatch: got %q expected <nil>", + resp.Response) + return + } + } + fmt.Fprint(w, "PASS") +} + +func nukeEntities(c appengine.Context, kinds []string) os.Error { + if !appengine.IsDevAppServer() { + return os.NewError("can't nuke production data") + } + var keys []*datastore.Key + for _, kind := range kinds { + q := datastore.NewQuery(kind).KeysOnly() + for t := q.Run(c); ; { + k, err := t.Next(nil) + if err == datastore.Done { + break + } + if err != nil { + return err + } + keys = append(keys, k) + } + } + return datastore.DeleteMulti(c, keys) +} diff --git a/misc/dashboard/app/build/ui.go b/misc/dashboard/app/build/ui.go new file mode 100644 index 000000000..032fdbd84 --- /dev/null +++ b/misc/dashboard/app/build/ui.go @@ -0,0 +1,313 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// TODO(adg): packages at weekly/release +// TODO(adg): some means to register new packages + +package build + +import ( + "bytes" + "exp/template/html" + "http" + "os" + "regexp" + "sort" + "strconv" + "strings" + "template" + + "appengine" + "appengine/datastore" + "cache" +) + +func init() { + http.HandleFunc("/", uiHandler) + html.Escape(uiTemplate) +} + +// uiHandler draws the build status page. +func uiHandler(w http.ResponseWriter, r *http.Request) { + c := appengine.NewContext(r) + now := cache.Now(c) + const key = "build-ui" + + page, _ := strconv.Atoi(r.FormValue("page")) + if page < 0 { + page = 0 + } + + // Used cached version of front page, if available. + if page == 0 { + var b []byte + if cache.Get(r, now, key, &b) { + w.Write(b) + return + } + } + + commits, err := goCommits(c, page) + if err != nil { + logErr(w, r, err) + return + } + builders := commitBuilders(commits) + + tipState, err := TagState(c, "tip") + if err != nil { + logErr(w, r, err) + return + } + + p := &Pagination{} + if len(commits) == commitsPerPage { + p.Next = page + 1 + } + if page > 0 { + p.Prev = page - 1 + p.HasPrev = true + } + data := &uiTemplateData{commits, builders, tipState, p} + + var buf bytes.Buffer + if err := uiTemplate.Execute(&buf, data); err != nil { + logErr(w, r, err) + return + } + + // Cache the front page. + if page == 0 { + cache.Set(r, now, key, buf.Bytes()) + } + + buf.WriteTo(w) +} + +type Pagination struct { + Next, Prev int + HasPrev bool +} + +// goCommits gets a slice of the latest Commits to the Go repository. +// If page > 0 it paginates by commitsPerPage. +func goCommits(c appengine.Context, page int) ([]*Commit, os.Error) { + q := datastore.NewQuery("Commit"). + Ancestor((&Package{}).Key(c)). + Order("-Time"). + Limit(commitsPerPage). + Offset(page * commitsPerPage) + var commits []*Commit + _, err := q.GetAll(c, &commits) + return commits, err +} + +// commitBuilders returns the names of the builders that provided +// Results for the provided commits. +func commitBuilders(commits []*Commit) []string { + builders := make(map[string]bool) + for _, commit := range commits { + for _, r := range commit.Results("") { + builders[r.Builder] = true + } + } + return keys(builders) +} + +func keys(m map[string]bool) (s []string) { + for k := range m { + s = append(s, k) + } + sort.Strings(s) + return +} + +// PackageState represents the state of a Package at a tag. +type PackageState struct { + *Package + *Commit + Results []*Result + OK bool +} + +// TagState fetches the results for all non-Go packages at the specified tag. +func TagState(c appengine.Context, name string) ([]*PackageState, os.Error) { + tag, err := GetTag(c, name) + if err != nil { + return nil, err + } + pkgs, err := Packages(c) + if err != nil { + return nil, err + } + var states []*PackageState + for _, pkg := range pkgs { + commit, err := pkg.LastCommit(c) + if err != nil { + c.Errorf("no Commit found: %v", pkg) + continue + } + results := commit.Results(tag.Hash) + ok := len(results) > 0 + for _, r := range results { + ok = ok && r.OK + } + states = append(states, &PackageState{ + pkg, commit, results, ok, + }) + } + return states, nil +} + +type uiTemplateData struct { + Commits []*Commit + Builders []string + TipState []*PackageState + Pagination *Pagination +} + +var uiTemplate = template.Must( + template.New("ui").Funcs(tmplFuncs).ParseFile("build/ui.html"), +) + +var tmplFuncs = template.FuncMap{ + "builderOS": builderOS, + "builderArch": builderArch, + "builderArchShort": builderArchShort, + "builderArchChar": builderArchChar, + "builderTitle": builderTitle, + "builderSpans": builderSpans, + "repoURL": repoURL, + "shortDesc": shortDesc, + "shortHash": shortHash, + "shortUser": shortUser, + "tail": tail, +} + +func splitDash(s string) (string, string) { + i := strings.Index(s, "-") + if i >= 0 { + return s[:i], s[i+1:] + } + return s, "" +} + +// builderOS returns the os tag for a builder string +func builderOS(s string) string { + os, _ := splitDash(s) + return os +} + +// builderArch returns the arch tag for a builder string +func builderArch(s string) string { + _, arch := splitDash(s) + arch, _ = splitDash(arch) // chop third part + return arch +} + +// builderArchShort returns a short arch tag for a builder string +func builderArchShort(s string) string { + arch := builderArch(s) + switch arch { + case "amd64": + return "x64" + } + return arch +} + +// builderArchChar returns the architecture letter for a builder string +func builderArchChar(s string) string { + arch := builderArch(s) + switch arch { + case "386": + return "8" + case "amd64": + return "6" + case "arm": + return "5" + } + return arch +} + +type builderSpan struct { + N int + OS string +} + +// builderSpans creates a list of tags showing +// the builder's operating system names, spanning +// the appropriate number of columns. +func builderSpans(s []string) []builderSpan { + var sp []builderSpan + for len(s) > 0 { + i := 1 + os := builderOS(s[0]) + for i < len(s) && builderOS(s[i]) == os { + i++ + } + sp = append(sp, builderSpan{i, os}) + s = s[i:] + } + return sp +} + +// builderTitle formats "linux-amd64-foo" as "linux amd64 foo". +func builderTitle(s string) string { + return strings.Replace(s, "-", " ", -1) +} + +// shortDesc returns the first line of a description. +func shortDesc(desc string) string { + if i := strings.Index(desc, "\n"); i != -1 { + desc = desc[:i] + } + return desc +} + +// shortHash returns a short version of a hash. +func shortHash(hash string) string { + if len(hash) > 12 { + hash = hash[:12] + } + return hash +} + +// shortUser returns a shortened version of a user string. +func shortUser(user string) string { + if i, j := strings.Index(user, "<"), strings.Index(user, ">"); 0 <= i && i < j { + user = user[i+1 : j] + } + if i := strings.Index(user, "@"); i >= 0 { + return user[:i] + } + return user +} + +// repoRe matches Google Code repositories and subrepositories (without paths). +var repoRe = regexp.MustCompile(`^code\.google\.com/p/([a-z0-9\-]+)(\.[a-z0-9\-]+)?$`) + +// repoURL returns the URL of a change at a Google Code repository or subrepo. +func repoURL(hash, packagePath string) (string, os.Error) { + if packagePath == "" { + return "https://code.google.com/p/go/source/detail?r=" + hash, nil + } + m := repoRe.FindStringSubmatch(packagePath) + if m == nil { + return "", os.NewError("unrecognized package: " + packagePath) + } + url := "https://code.google.com/p/" + m[1] + "/source/detail?r=" + hash + if len(m) > 2 { + url += "&repo=" + m[2][1:] + } + return url, nil +} + +// tail returns the trailing n lines of s. +func tail(n int, s string) string { + lines := strings.Split(s, "\n") + if len(lines) < n { + return s + } + return strings.Join(lines[len(lines)-n:], "\n") +} diff --git a/misc/dashboard/app/build/ui.html b/misc/dashboard/app/build/ui.html new file mode 100644 index 000000000..678c95238 --- /dev/null +++ b/misc/dashboard/app/build/ui.html @@ -0,0 +1,178 @@ +<!DOCTYPE HTML> +<html> + <head> + <title>Go Build Dashboard</title> + <style> + body { + font-family: sans-serif; + padding: 0; margin: 0; + } + h1, h2 { + margin: 0; + padding: 5px; + } + h1 { + background: #eee; + } + h2 { + margin-top: 10px; + } + .build, .packages { + margin: 5px; + border-collapse: collapse; + } + .build td, .build th, .packages td, .packages th { + vertical-align: top; + padding: 2px 4px; + font-size: 10pt; + } + .build tr.commit:nth-child(2n) { + background-color: #f0f0f0; + } + .build .hash { + font-family: monospace; + font-size: 9pt; + } + .build .result { + text-align: center; + width: 2em; + } + .col-hash, .col-result { + border-right: solid 1px #ccc; + } + .build .arch { + font-size: 66%; + font-weight: normal; + } + .build .time { + color: #666; + } + .build .ok { + font-size: 83%; + } + .build .desc, .build .time, .build .user { + white-space: nowrap; + } + .paginate { + padding: 0.5em; + } + .paginate a { + padding: 0.5em; + background: #eee; + color: blue; + } + .paginate a.inactive { + color: #999; + } + .fail { + color: #C00; + } + </style> + </head> + <body> + + <h1>Go Build Status</h1> + + {{if $.Commits}} + + <table class="build"> + <colgroup class="col-hash"></colgroup> + {{range $.Builders | builderSpans}} + <colgroup class="col-result" span="{{.N}}"></colgroup> + {{end}} + <colgroup class="col-user"></colgroup> + <colgroup class="col-time"></colgroup> + <colgroup class="col-desc"></colgroup> + <tr> + <!-- extra row to make alternating colors use dark for first result --> + </tr> + <tr> + <th> </th> + {{range $.Builders | builderSpans}} + <th colspan="{{.N}}">{{.OS}}</th> + {{end}} + <th></th> + <th></th> + <th></th> + </tr> + <tr> + <th> </th> + {{range $.Builders}} + <th class="result arch" title="{{.}}">{{builderArchShort .}}</th> + {{end}} + </tr> + {{range $c := $.Commits}} + <tr class="commit"> + <td class="hash"><a href="{{repoURL .Hash ""}}">{{shortHash .Hash}}</a></td> + {{range $.Builders}} + <td class="result"> + {{with $c.Result . ""}} + {{if .OK}} + <span class="ok">ok</span> + {{else}} + <a href="/log/{{.LogHash}}" class="fail">fail</a> + {{end}} + {{else}} + + {{end}} + </td> + {{end}} + <td class="user" title="{{.User}}">{{shortUser .User}}</td> + <td class="time">{{.Time.Time.Format "Mon 02 Jan 15:04"}}</td> + <td class="desc" title="{{.Desc}}">{{shortDesc .Desc}}</td> + </tr> + {{end}} + </table> + + {{with $.Pagination}} + <div class="paginate"> + <a {{if .HasPrev}}href="?page={{.Prev}}"{{else}}class="inactive"{{end}}>prev</a> + <a {{if .Next}}href="?page={{.Next}}"{{else}}class="inactive"{{end}}>next</a> + <a {{if .HasPrev}}href="?page=0}"{{else}}class="inactive"{{end}}>top</a> + </div> + {{end}} + + {{else}} + <p>No commits to display. Hm.</p> + {{end}} + + {{if $.TipState}} + <h2>Other packages</h2> + + <table class="packages"> + <tr> + <th>State</th> + <th>Package</th> + <th> </th> + </tr> + {{range $state := $.TipState}} + <tr> + <td> + {{if .Results}} + <img src="/static/status_{{if .OK}}good{{else}}alert{{end}}.gif" /> + {{else}} + + {{end}} + </td> + <td><a title="{{.Package.Path}}">{{.Package.Name}}</a></td> + <td> + {{range .Results}} + <div> + {{$h := $state.Commit.Hash}} + <a href="{{repoURL $h $state.Commit.PackagePath}}">{{shortHash $h}}</a> + {{if .OK}} + ok + {{else}} + <a href="/log/{{.LogHash}}" class="fail">failed</a> + {{end}} + on {{.Builder}}/<a href="{{repoURL .GoHash ""}}">{{shortHash .GoHash}}</a> + </a></div> + {{end}} + </td> + </tr> + {{end}} + </table> + {{end}} + + </body> +</html> diff --git a/misc/dashboard/app/cache/cache.go b/misc/dashboard/app/cache/cache.go new file mode 100644 index 000000000..d290ed416 --- /dev/null +++ b/misc/dashboard/app/cache/cache.go @@ -0,0 +1,82 @@ +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cache + +import ( + "fmt" + "http" + "time" + + "appengine" + "appengine/memcache" +) + +const ( + nocache = "nocache" + timeKey = "cachetime" + expiry = 600 // 10 minutes +) + +func newTime() uint64 { return uint64(time.Seconds()) << 32 } + +// Now returns the current logical datastore time to use for cache lookups. +func Now(c appengine.Context) uint64 { + t, err := memcache.Increment(c, timeKey, 0, newTime()) + if err != nil { + c.Errorf("cache.Now: %v", err) + return 0 + } + return t +} + +// Tick sets the current logical datastore time to a never-before-used time +// and returns that time. It should be called to invalidate the cache. +func Tick(c appengine.Context) uint64 { + t, err := memcache.Increment(c, timeKey, 1, newTime()) + if err != nil { + c.Errorf("cache.Tick: %v", err) + return 0 + } + return t +} + +// Get fetches data for name at time now from memcache and unmarshals it into +// value. It reports whether it found the cache record and logs any errors to +// the admin console. +func Get(r *http.Request, now uint64, name string, value interface{}) bool { + if now == 0 || r.FormValue(nocache) != "" { + return false + } + c := appengine.NewContext(r) + key := fmt.Sprintf("%s.%d", name, now) + _, err := memcache.JSON.Get(c, key, value) + if err == nil { + c.Debugf("cache hit %q", key) + return true + } + c.Debugf("cache miss %q", key) + if err != memcache.ErrCacheMiss { + c.Errorf("get cache %q: %v", key, err) + } + return false +} + +// Set puts value into memcache under name at time now. +// It logs any errors to the admin console. +func Set(r *http.Request, now uint64, name string, value interface{}) { + if now == 0 || r.FormValue(nocache) != "" { + return + } + c := appengine.NewContext(r) + key := fmt.Sprintf("%s.%d", name, now) + err := memcache.JSON.Set(c, &memcache.Item{ + Key: key, + Object: value, + Expiration: expiry, + }) + if err != nil { + c.Errorf("set cache %q: %v", key, err) + } +} diff --git a/misc/dashboard/app/static/status_alert.gif b/misc/dashboard/app/static/status_alert.gif Binary files differnew file mode 100644 index 000000000..495d9d2e0 --- /dev/null +++ b/misc/dashboard/app/static/status_alert.gif diff --git a/misc/dashboard/app/static/status_good.gif b/misc/dashboard/app/static/status_good.gif Binary files differnew file mode 100644 index 000000000..ef9c5a8f6 --- /dev/null +++ b/misc/dashboard/app/static/status_good.gif diff --git a/misc/dashboard/builder/exec.go b/misc/dashboard/builder/exec.go index a042c5699..7f21abaa2 100644 --- a/misc/dashboard/builder/exec.go +++ b/misc/dashboard/builder/exec.go @@ -6,15 +6,15 @@ package main import ( "bytes" - "exec" "io" "log" "os" + "os/exec" "strings" ) // run is a simple wrapper for exec.Run/Close -func run(envv []string, dir string, argv ...string) os.Error { +func run(envv []string, dir string, argv ...string) error { if *verbose { log.Println("run", argv) } @@ -31,7 +31,7 @@ func run(envv []string, dir string, argv ...string) os.Error { // process combined stdout and stderr output, exit status and error. // The error returned is nil, if process is started successfully, // even if exit status is not 0. -func runLog(envv []string, logfile, dir string, argv ...string) (string, int, os.Error) { +func runLog(envv []string, logfile, dir string, argv ...string) (string, int, error) { if *verbose { log.Println("runLog", argv) } @@ -56,11 +56,11 @@ func runLog(envv []string, logfile, dir string, argv ...string) (string, int, os err := cmd.Run() if err != nil { - if ws, ok := err.(*os.Waitmsg); ok { + if ws, ok := err.(*exec.ExitError); ok { return b.String(), ws.ExitStatus(), nil } } - return b.String(), 0, nil + return b.String(), 0, err } // useBash prefixes a list of args with 'bash' if the first argument diff --git a/misc/dashboard/builder/http.go b/misc/dashboard/builder/http.go index abef8faa4..b25b417e1 100644 --- a/misc/dashboard/builder/http.go +++ b/misc/dashboard/builder/http.go @@ -6,98 +6,129 @@ package main import ( "bytes" + "encoding/json" + "errors" "fmt" - "http" - "json" + "io" "log" - "os" - "strconv" - "url" + "net/http" + "net/url" + "time" ) -type param map[string]string +type obj map[string]interface{} // dash runs the given method and command on the dashboard. -// If args is not nil, it is the query or post parameters. -// If resp is not nil, dash unmarshals the body as JSON into resp. -func dash(meth, cmd string, resp interface{}, args param) os.Error { +// If args is non-nil it is encoded as the URL query string. +// If req is non-nil it is JSON-encoded and passed as the body of the HTTP POST. +// If resp is non-nil the server's response is decoded into the value pointed +// to by resp (resp must be a pointer). +func dash(meth, cmd string, args url.Values, req, resp interface{}) error { var r *http.Response - var err os.Error + var err error if *verbose { - log.Println("dash", cmd, args) + log.Println("dash", meth, cmd, args, req) } cmd = "http://" + *dashboard + "/" + cmd - vals := make(url.Values) - for k, v := range args { - vals.Add(k, v) + if len(args) > 0 { + cmd += "?" + args.Encode() } switch meth { case "GET": - if q := vals.Encode(); q != "" { - cmd += "?" + q + if req != nil { + log.Panicf("%s to %s with req", meth, cmd) } r, err = http.Get(cmd) case "POST": - r, err = http.PostForm(cmd, vals) + var body io.Reader + if req != nil { + b, err := json.Marshal(req) + if err != nil { + return err + } + body = bytes.NewBuffer(b) + } + r, err = http.Post(cmd, "text/json", body) default: - return fmt.Errorf("unknown method %q", meth) + log.Panicf("%s: invalid method %q", cmd, meth) + panic("invalid method: " + meth) } if err != nil { return err } + defer r.Body.Close() - var buf bytes.Buffer - buf.ReadFrom(r.Body) - if resp != nil { - if err = json.Unmarshal(buf.Bytes(), resp); err != nil { - log.Printf("json unmarshal %#q: %s\n", buf.Bytes(), err) - return err - } + body := new(bytes.Buffer) + if _, err := body.ReadFrom(r.Body); err != nil { + return err } - return nil -} -func dashStatus(meth, cmd string, args param) os.Error { - var resp struct { - Status string - Error string + // Read JSON-encoded Response into provided resp + // and return an error if present. + var result = struct { + Response interface{} + Error string + }{ + // Put the provided resp in here as it can be a pointer to + // some value we should unmarshal into. + Response: resp, } - err := dash(meth, cmd, &resp, args) - if err != nil { + if err = json.Unmarshal(body.Bytes(), &result); err != nil { + log.Printf("json unmarshal %#q: %s\n", body.Bytes(), err) return err } - if resp.Status != "OK" { - return os.NewError("/build: " + resp.Error) + if result.Error != "" { + return errors.New(result.Error) } + return nil } // todo returns the next hash to build. -func (b *Builder) todo() (rev string, err os.Error) { - var resp []struct { - Hash string +func (b *Builder) todo(kind, pkg, goHash string) (rev string, err error) { + args := url.Values{ + "kind": {kind}, + "builder": {b.name}, + "packagePath": {pkg}, + "goHash": {goHash}, } - if err = dash("GET", "todo", &resp, param{"builder": b.name}); err != nil { - return + var resp *struct { + Kind string + Data struct { + Hash string + } } - if len(resp) > 0 { - rev = resp[0].Hash + if err = dash("GET", "todo", args, nil, &resp); err != nil { + return "", err } - return + if resp == nil { + return "", nil + } + if kind != resp.Kind { + return "", fmt.Errorf("expecting Kind %q, got %q", kind, resp.Kind) + } + return resp.Data.Hash, nil } // recordResult sends build results to the dashboard -func (b *Builder) recordResult(buildLog string, hash string) os.Error { - return dash("POST", "build", nil, param{ - "builder": b.name, - "key": b.key, - "node": hash, - "log": buildLog, - }) +func (b *Builder) recordResult(ok bool, pkg, hash, goHash, buildLog string, runTime time.Duration) error { + req := obj{ + "Builder": b.name, + "PackagePath": pkg, + "Hash": hash, + "GoHash": goHash, + "OK": ok, + "Log": buildLog, + "RunTime": runTime, + } + args := url.Values{"key": {b.key}, "builder": {b.name}} + return dash("POST", "result", args, req, nil) } // packages fetches a list of package paths from the dashboard -func packages() (pkgs []string, err os.Error) { +func packages() (pkgs []string, err error) { + return nil, nil + /* TODO(adg): un-stub this once the new package builder design is done var resp struct { Packages []struct { Path string @@ -111,38 +142,58 @@ func packages() (pkgs []string, err os.Error) { pkgs = append(pkgs, p.Path) } return + */ } -// updatePackage sends package build results and info dashboard -func (b *Builder) updatePackage(pkg string, ok bool, buildLog, info string) os.Error { +// updatePackage sends package build results and info to the dashboard +func (b *Builder) updatePackage(pkg string, ok bool, buildLog, info string) error { + return nil + /* TODO(adg): un-stub this once the new package builder design is done return dash("POST", "package", nil, param{ "builder": b.name, "key": b.key, "path": pkg, - "ok": strconv.Btoa(ok), + "ok": strconv.FormatBool(ok), "log": buildLog, "info": info, }) + */ } -// postCommit informs the dashboard of a new commit -func postCommit(key string, l *HgLog) os.Error { - return dashStatus("POST", "commit", param{ - "key": key, - "node": l.Hash, - "date": l.Date, - "user": l.Author, - "parent": l.Parent, - "desc": l.Desc, - }) +func postCommit(key, pkg string, l *HgLog) error { + t, err := time.Parse(time.RFC3339, l.Date) + if err != nil { + return fmt.Errorf("parsing %q: %v", l.Date, t) + } + return dash("POST", "commit", url.Values{"key": {key}}, obj{ + "PackagePath": pkg, + "Hash": l.Hash, + "ParentHash": l.Parent, + "Time": t.Unix() * 1e6, // in microseconds, yuck! + "User": l.Author, + "Desc": l.Desc, + }, nil) } -// dashboardCommit returns true if the dashboard knows about hash. -func dashboardCommit(hash string) bool { - err := dashStatus("GET", "commit", param{"node": hash}) - if err != nil { - log.Printf("check %s: %s", hash, err) - return false +func dashboardCommit(pkg, hash string) bool { + err := dash("GET", "commit", url.Values{ + "packagePath": {pkg}, + "hash": {hash}, + }, nil, nil) + return err == nil +} + +func dashboardPackages() []string { + var resp []struct { + Path string + } + if err := dash("GET", "packages", nil, nil, &resp); err != nil { + log.Println("dashboardPackages:", err) + return nil + } + var pkgs []string + for _, r := range resp { + pkgs = append(pkgs, r.Path) } - return true + return pkgs } diff --git a/misc/dashboard/builder/main.go b/misc/dashboard/builder/main.go index a5479846d..3556a457d 100644 --- a/misc/dashboard/builder/main.go +++ b/misc/dashboard/builder/main.go @@ -5,38 +5,39 @@ package main import ( + "encoding/xml" + "errors" "flag" "fmt" "io/ioutil" "log" "os" "path" + "path/filepath" "regexp" "runtime" "strconv" "strings" "time" - "xml" ) const ( codeProject = "go" codePyScript = "misc/dashboard/googlecode_upload.py" hgUrl = "https://go.googlecode.com/hg/" - waitInterval = 30e9 // time to wait before checking for new revs mkdirPerm = 0750 - pkgBuildInterval = 1e9 * 60 * 60 * 24 // rebuild packages every 24 hours + waitInterval = 30 * time.Second // time to wait before checking for new revs + pkgBuildInterval = 24 * time.Hour // rebuild packages every 24 hours ) // These variables are copied from the gobuilder's environment // to the envv of its subprocesses. var extraEnv = []string{ - "GOHOSTOS", + "GOARM", "GOHOSTARCH", + "GOHOSTOS", "PATH", - "DISABLE_NET_TESTS", - "MAKEFLAGS", - "GOARM", + "TMPDIR", } type Builder struct { @@ -50,7 +51,7 @@ type Builder struct { var ( buildroot = flag.String("buildroot", path.Join(os.TempDir(), "gobuilder"), "Directory under which to build") commitFlag = flag.Bool("commit", false, "upload information about new commits") - dashboard = flag.String("dashboard", "godashboard.appspot.com", "Go Dashboard Host") + dashboard = flag.String("dashboard", "build.golang.org", "Go Dashboard Host") buildRelease = flag.Bool("release", false, "Build and upload binary release archives") buildRevision = flag.String("rev", "", "Build specified revision and exit") buildCmd = flag.String("cmd", "./all.bash", "Build command (specify absolute or relative to go/src/)") @@ -92,7 +93,7 @@ func main() { if err := os.Mkdir(*buildroot, mkdirPerm); err != nil { log.Fatalf("Error making build root (%s): %s", *buildroot, err) } - if err := run(nil, *buildroot, "hg", "clone", hgUrl, goroot); err != nil { + if err := hgClone(hgUrl, goroot); err != nil { log.Fatal("Error cloning repository:", err) } @@ -106,7 +107,7 @@ func main() { // if specified, build revision and return if *buildRevision != "" { - hash, err := fullHash(*buildRevision) + hash, err := fullHash(goroot, *buildRevision) if err != nil { log.Fatal("Error finding revision: ", err) } @@ -130,7 +131,7 @@ func main() { // check for new commits and build them for { built := false - t := time.Nanoseconds() + t := time.Now() if *parallel { done := make(chan bool) for _, b := range builders { @@ -151,14 +152,14 @@ func main() { time.Sleep(waitInterval) } // sleep if we're looping too fast. - t1 := time.Nanoseconds() - t - if t1 < waitInterval { - time.Sleep(waitInterval - t1) + dt := time.Now().Sub(t) + if dt < waitInterval { + time.Sleep(waitInterval - dt) } } } -func NewBuilder(builder string) (*Builder, os.Error) { +func NewBuilder(builder string) (*Builder, error) { b := &Builder{name: builder} // get goos/goarch from builder string @@ -193,7 +194,7 @@ func NewBuilder(builder string) (*Builder, os.Error) { // a new release tag is found. func (b *Builder) buildExternal() { var prevTag string - var nextBuild int64 + var nextBuild time.Time for { time.Sleep(waitInterval) err := run(nil, goroot, "hg", "pull", "-u") @@ -212,7 +213,7 @@ func (b *Builder) buildExternal() { // don't rebuild if there's no new release // and it's been less than pkgBuildInterval // nanoseconds since the last build. - if tag == prevTag && time.Nanoseconds() < nextBuild { + if tag == prevTag && time.Now().Before(nextBuild) { continue } // build will also build the packages @@ -221,7 +222,7 @@ func (b *Builder) buildExternal() { continue } prevTag = tag - nextBuild = time.Nanoseconds() + pkgBuildInterval + nextBuild = time.Now().Add(pkgBuildInterval) } } @@ -235,7 +236,7 @@ func (b *Builder) build() bool { log.Println(b.name, "build:", err) } }() - hash, err := b.todo() + hash, err := b.todo("build-go-commit", "", "") if err != nil { log.Println(err) return false @@ -245,7 +246,7 @@ func (b *Builder) build() bool { } // Look for hash locally before running hg pull. - if _, err := fullHash(hash[:12]); err != nil { + if _, err := fullHash(goroot, hash[:12]); err != nil { // Don't have hash, so run hg pull. if err := run(nil, goroot, "hg", "pull"); err != nil { log.Println("hg pull failed:", err) @@ -259,7 +260,7 @@ func (b *Builder) build() bool { return true } -func (b *Builder) buildHash(hash string) (err os.Error) { +func (b *Builder) buildHash(hash string) (err error) { defer func() { if err != nil { err = fmt.Errorf("%s build: %s: %s", b.name, hash, err) @@ -283,8 +284,7 @@ func (b *Builder) buildHash(hash string) (err os.Error) { } // update to specified revision - err = run(nil, path.Join(workpath, "go"), - "hg", "update", hash) + err = run(nil, path.Join(workpath, "go"), "hg", "update", hash) if err != nil { return } @@ -293,7 +293,9 @@ func (b *Builder) buildHash(hash string) (err os.Error) { // build logfile := path.Join(workpath, "build.log") + startTime := time.Now() buildLog, status, err := runLog(b.envv(), logfile, srcDir, *buildCmd) + runTime := time.Now().Sub(startTime) if err != nil { return fmt.Errorf("%s: %s", *buildCmd, err) } @@ -301,21 +303,24 @@ func (b *Builder) buildHash(hash string) (err os.Error) { // if we're in external mode, build all packages and return if *external { if status != 0 { - return os.NewError("go build failed") + return errors.New("go build failed") } - return b.buildPackages(workpath, hash) + return b.buildExternalPackages(workpath, hash) } if status != 0 { // record failure - return b.recordResult(buildLog, hash) + return b.recordResult(false, "", hash, "", buildLog, runTime) } // record success - if err = b.recordResult("", hash); err != nil { + if err = b.recordResult(true, "", hash, "", "", runTime); err != nil { return fmt.Errorf("recordResult: %s", err) } + // build goinstallable packages + b.buildPackages(filepath.Join(workpath, "go"), hash) + // finish here if codeUsername and codePassword aren't set if b.codeUsername == "" || b.codePassword == "" || !*buildRelease { return @@ -342,11 +347,67 @@ func (b *Builder) buildHash(hash string) (err os.Error) { "-w", b.codePassword, "-l", fmt.Sprintf("%s,%s", b.goos, b.goarch), fn) + if err != nil { + return fmt.Errorf("%s: %s", codePyScript, err) + } } return } +func (b *Builder) buildPackages(goRoot, goHash string) { + for _, pkg := range dashboardPackages() { + // get the latest todo for this package + hash, err := b.todo("build-package", pkg, goHash) + if err != nil { + log.Printf("buildPackages %s: %v", pkg, err) + continue + } + if hash == "" { + continue + } + + // goinstall the package + if *verbose { + log.Printf("buildPackages %s: installing %q", pkg, hash) + } + buildLog, err := b.goinstall(goRoot, pkg, hash) + ok := buildLog == "" + if err != nil { + ok = false + log.Printf("buildPackages %s: %v", pkg, err) + } + + // record the result + err = b.recordResult(ok, pkg, hash, goHash, buildLog, 0) + if err != nil { + log.Printf("buildPackages %s: %v", pkg, err) + } + } +} + +func (b *Builder) goinstall(goRoot, pkg, hash string) (string, error) { + bin := filepath.Join(goRoot, "bin/goinstall") + env := append(b.envv(), "GOROOT="+goRoot) + + // fetch package and dependencies + log, status, err := runLog(env, "", goRoot, bin, + "-dashboard=false", "-install=false", pkg) + if err != nil || status != 0 { + return log, err + } + + // hg update to the specified hash + pkgPath := filepath.Join(goRoot, "src/pkg", pkg) + if err := run(nil, pkgPath, "hg", "update", hash); err != nil { + return "", err + } + + // build the package + log, _, err = runLog(env, "", goRoot, bin, "-dashboard=false", pkg) + return log, err +} + // envv returns an environment for build/bench execution func (b *Builder) envv() []string { if runtime.GOOS == "windows" { @@ -409,12 +470,12 @@ func (b *Builder) envvWindows() []string { func isDirectory(name string) bool { s, err := os.Stat(name) - return err == nil && s.IsDirectory() + return err == nil && s.IsDir() } func isFile(name string) bool { s, err := os.Stat(name) - return err == nil && (s.IsRegular() || s.IsSymlink()) + return err == nil && !s.IsDir() } // commitWatcher polls hg for new commits and tells the dashboard about them. @@ -424,11 +485,16 @@ func commitWatcher() { if err != nil { log.Fatal(err) } + key := b.key + for { if *verbose { log.Printf("poll...") } - commitPoll(b.key) + commitPoll(key, "") + for _, pkg := range dashboardPackages() { + commitPoll(key, pkg) + } if *verbose { log.Printf("sleep...") } @@ -436,6 +502,18 @@ func commitWatcher() { } } +func hgClone(url, path string) error { + return run(nil, *buildroot, "hg", "clone", url, path) +} + +func hgRepoExists(path string) bool { + fi, err := os.Stat(filepath.Join(path, ".hg")) + if err != nil { + return false + } + return fi.IsDir() +} + // HgLog represents a single Mercurial revision. type HgLog struct { Hash string @@ -455,18 +533,18 @@ var logByHash = map[string]*HgLog{} // xmlLogTemplate is a template to pass to Mercurial to make // hg log print the log in valid XML for parsing with xml.Unmarshal. const xmlLogTemplate = ` - <log> - <hash>{node|escape}</hash> - <parent>{parent|escape}</parent> - <author>{author|escape}</author> - <date>{date}</date> - <desc>{desc|escape}</desc> - </log> + <Log> + <Hash>{node|escape}</Hash> + <Parent>{parent|escape}</Parent> + <Author>{author|escape}</Author> + <Date>{date|rfc3339date}</Date> + <Desc>{desc|escape}</Desc> + </Log> ` // commitPoll pulls any new revisions from the hg server // and tells the server about them. -func commitPoll(key string) { +func commitPoll(key, pkg string) { // Catch unexpected panics. defer func() { if err := recover(); err != nil { @@ -474,14 +552,29 @@ func commitPoll(key string) { } }() - if err := run(nil, goroot, "hg", "pull"); err != nil { + pkgRoot := goroot + + if pkg != "" { + pkgRoot = path.Join(*buildroot, pkg) + if !hgRepoExists(pkgRoot) { + if err := hgClone(repoURL(pkg), pkgRoot); err != nil { + log.Printf("%s: hg clone failed: %v", pkg, err) + if err := os.RemoveAll(pkgRoot); err != nil { + log.Printf("%s: %v", pkg, err) + } + return + } + } + } + + if err := run(nil, pkgRoot, "hg", "pull"); err != nil { log.Printf("hg pull: %v", err) return } const N = 50 // how many revisions to grab - data, _, err := runLog(nil, "", goroot, "hg", "log", + data, _, err := runLog(nil, "", pkgRoot, "hg", "log", "--encoding=utf-8", "--limit="+strconv.Itoa(N), "--template="+xmlLogTemplate, @@ -494,7 +587,7 @@ func commitPoll(key string) { var logStruct struct { Log []HgLog } - err = xml.Unmarshal(strings.NewReader("<top>"+data+"</top>"), &logStruct) + err = xml.Unmarshal([]byte("<Top>"+data+"</Top>"), &logStruct) if err != nil { log.Printf("unmarshal hg log: %v", err) return @@ -510,14 +603,11 @@ func commitPoll(key string) { if l.Parent == "" && i+1 < len(logs) { l.Parent = logs[i+1].Hash } else if l.Parent != "" { - l.Parent, _ = fullHash(l.Parent) + l.Parent, _ = fullHash(pkgRoot, l.Parent) } - log.Printf("hg log: %s < %s\n", l.Hash, l.Parent) - if l.Parent == "" { - // Can't create node without parent. - continue + if *verbose { + log.Printf("hg log %s: %s < %s\n", pkg, l.Hash, l.Parent) } - if logByHash[l.Hash] == nil { // Make copy to avoid pinning entire slice when only one entry is new. t := *l @@ -527,17 +617,14 @@ func commitPoll(key string) { for i := range logs { l := &logs[i] - if l.Parent == "" { - continue - } - addCommit(l.Hash, key) + addCommit(pkg, l.Hash, key) } } // addCommit adds the commit with the named hash to the dashboard. // key is the secret key for authentication to the dashboard. // It avoids duplicate effort. -func addCommit(hash, key string) bool { +func addCommit(pkg, hash, key string) bool { l := logByHash[hash] if l == nil { return false @@ -547,7 +634,7 @@ func addCommit(hash, key string) bool { } // Check for already added, perhaps in an earlier run. - if dashboardCommit(hash) { + if dashboardCommit(pkg, hash) { log.Printf("%s already on dashboard\n", hash) // Record that this hash is on the dashboard, // as must be all its parents. @@ -559,12 +646,14 @@ func addCommit(hash, key string) bool { } // Create parent first, to maintain some semblance of order. - if !addCommit(l.Parent, key) { - return false + if l.Parent != "" { + if !addCommit(pkg, l.Parent, key) { + return false + } } // Create commit. - if err := postCommit(key, l); err != nil { + if err := postCommit(key, pkg, l); err != nil { log.Printf("failed to add %s to dashboard: %v", key, err) return false } @@ -572,13 +661,13 @@ func addCommit(hash, key string) bool { } // fullHash returns the full hash for the given Mercurial revision. -func fullHash(rev string) (hash string, err os.Error) { +func fullHash(root, rev string) (hash string, err error) { defer func() { if err != nil { err = fmt.Errorf("fullHash: %s: %s", rev, err) } }() - s, _, err := runLog(nil, "", goroot, + s, _, err := runLog(nil, "", root, "hg", "log", "--encoding=utf-8", "--rev="+rev, @@ -601,7 +690,7 @@ func fullHash(rev string) (hash string, err os.Error) { var revisionRe = regexp.MustCompile(`^([^ ]+) +[0-9]+:([0-9a-f]+)$`) // firstTag returns the hash and tag of the most recent tag matching re. -func firstTag(re *regexp.Regexp) (hash string, tag string, err os.Error) { +func firstTag(re *regexp.Regexp) (hash string, tag string, err error) { o, _, err := runLog(nil, "", goroot, "hg", "tags") for _, l := range strings.Split(o, "\n") { if l == "" { @@ -609,16 +698,28 @@ func firstTag(re *regexp.Regexp) (hash string, tag string, err os.Error) { } s := revisionRe.FindStringSubmatch(l) if s == nil { - err = os.NewError("couldn't find revision number") + err = errors.New("couldn't find revision number") return } if !re.MatchString(s[1]) { continue } tag = s[1] - hash, err = fullHash(s[2]) + hash, err = fullHash(goroot, s[2]) return } - err = os.NewError("no matching tag found") + err = errors.New("no matching tag found") return } + +var repoRe = regexp.MustCompile(`^code\.google\.com/p/([a-z0-9\-]+(\.[a-z0-9\-]+)?)(/[a-z0-9A-Z_.\-/]+)?$`) + +// repoURL returns the repository URL for the supplied import path. +func repoURL(importPath string) string { + m := repoRe.FindStringSubmatch(importPath) + if len(m) < 2 { + log.Printf("repoURL: couldn't decipher %q", importPath) + return "" + } + return "https://code.google.com/p/" + m[1] +} diff --git a/misc/dashboard/builder/package.go b/misc/dashboard/builder/package.go index ebf4dd3c9..dcd449ab8 100644 --- a/misc/dashboard/builder/package.go +++ b/misc/dashboard/builder/package.go @@ -5,6 +5,7 @@ package main import ( + "errors" "fmt" "go/doc" "go/parser" @@ -17,7 +18,7 @@ import ( const MaxCommentLength = 500 // App Engine won't store more in a StringProperty. -func (b *Builder) buildPackages(workpath string, hash string) os.Error { +func (b *Builder) buildExternalPackages(workpath string, hash string) error { logdir := filepath.Join(*buildroot, "log") if err := os.Mkdir(logdir, 0755); err != nil { return err @@ -80,14 +81,14 @@ func (b *Builder) buildPackages(workpath string, hash string) os.Error { return nil } -func isGoFile(fi *os.FileInfo) bool { - return fi.IsRegular() && // exclude directories - !strings.HasPrefix(fi.Name, ".") && // ignore .files - !strings.HasSuffix(fi.Name, "_test.go") && // ignore tests - filepath.Ext(fi.Name) == ".go" +func isGoFile(fi os.FileInfo) bool { + return !fi.IsDir() && // exclude directories + !strings.HasPrefix(fi.Name(), ".") && // ignore .files + !strings.HasSuffix(fi.Name(), "_test.go") && // ignore tests + filepath.Ext(fi.Name()) == ".go" } -func packageComment(pkg, pkgpath string) (info string, err os.Error) { +func packageComment(pkg, pkgpath string) (info string, err error) { fset := token.NewFileSet() pkgs, err := parser.ParseDir(fset, pkgpath, isGoFile, parser.PackageClauseOnly|parser.ParseComments) if err != nil { @@ -97,12 +98,12 @@ func packageComment(pkg, pkgpath string) (info string, err os.Error) { if name == "main" { continue } - pdoc := doc.NewPackageDoc(pkgs[name], pkg) + pdoc := doc.New(pkgs[name], pkg, doc.AllDecls) if pdoc.Doc == "" { continue } if info != "" { - return "", os.NewError("multiple packages with docs") + return "", errors.New("multiple packages with docs") } info = pdoc.Doc } diff --git a/misc/dashboard/godashboard/app.yaml b/misc/dashboard/godashboard/app.yaml index 215c16330..8c7670437 100644 --- a/misc/dashboard/godashboard/app.yaml +++ b/misc/dashboard/godashboard/app.yaml @@ -1,5 +1,5 @@ application: godashboard -version: 8 +version: 9 runtime: python api_version: 1 @@ -21,5 +21,6 @@ handlers: - url: /project.* script: package.py -- url: /.* - script: gobuild.py +- url: / + static_files: main.html + upload: main.html diff --git a/misc/dashboard/godashboard/fail-notify.txt b/misc/dashboard/godashboard/fail-notify.txt index a699005ea..f75d09aa2 100644 --- a/misc/dashboard/godashboard/fail-notify.txt +++ b/misc/dashboard/godashboard/fail-notify.txt @@ -4,3 +4,6 @@ http://godashboard.appspot.com/log/{{loghash}} {{desc}} http://code.google.com/p/go/source/detail?r={{node}} + +$ tail -n 100 < log +{{log}} diff --git a/misc/dashboard/godashboard/gobuild.py b/misc/dashboard/godashboard/gobuild.py deleted file mode 100644 index ae8d99b3f..000000000 --- a/misc/dashboard/godashboard/gobuild.py +++ /dev/null @@ -1,561 +0,0 @@ -# Copyright 2009 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. - -# This is the server part of the continuous build system for Go. It must be run -# by AppEngine. - -from django.utils import simplejson -from google.appengine.api import mail -from google.appengine.api import memcache -from google.appengine.ext import db -from google.appengine.ext import webapp -from google.appengine.ext.webapp import template -from google.appengine.ext.webapp.util import run_wsgi_app -import datetime -import hashlib -import logging -import os -import re -import bz2 - -# local imports -from auth import auth -import const - -# The majority of our state are commit objects. One of these exists for each of -# the commits known to the build system. Their key names are of the form -# <commit number (%08x)> "-" <hg hash>. This means that a sorting by the key -# name is sufficient to order the commits. -# -# The commit numbers are purely local. They need not match up to the commit -# numbers in an hg repo. When inserting a new commit, the parent commit must be -# given and this is used to generate the new commit number. In order to create -# the first Commit object, a special command (/init) is used. -# -# N.B. user is a StringProperty, so it must be type 'unicode'. -# desc is a BlobProperty, so it must be type 'string'. [sic] -class Commit(db.Model): - num = db.IntegerProperty() # internal, monotonic counter. - node = db.StringProperty() # Hg hash - parentnode = db.StringProperty() # Hg hash - user = db.StringProperty() - date = db.DateTimeProperty() - desc = db.BlobProperty() - - # This is the list of builds. Each element is a string of the form <builder - # name> '`' <log hash>. If the log hash is empty, then the build was - # successful. - builds = db.StringListProperty() - - fail_notification_sent = db.BooleanProperty() - -# A CompressedLog contains the textual build log of a failed build. -# The key name is the hex digest of the SHA256 hash of the contents. -# The contents is bz2 compressed. -class CompressedLog(db.Model): - log = db.BlobProperty() - -N = 30 - -def builderInfo(b): - f = b.split('-', 3) - goos = f[0] - goarch = f[1] - note = "" - if len(f) > 2: - note = f[2] - return {'name': b, 'goos': goos, 'goarch': goarch, 'note': note} - -def builderset(): - q = Commit.all() - q.order('-__key__') - results = q.fetch(N) - builders = set() - for c in results: - builders.update(set(parseBuild(build)['builder'] for build in c.builds)) - return builders - -class MainPage(webapp.RequestHandler): - def get(self): - self.response.headers['Content-Type'] = 'text/html; charset=utf-8' - - try: - page = int(self.request.get('p', 1)) - if not page > 0: - raise - except: - page = 1 - - try: - num = int(self.request.get('n', N)) - if num <= 0 or num > 200: - raise - except: - num = N - - offset = (page-1) * num - - q = Commit.all() - q.order('-__key__') - results = q.fetch(num, offset) - - revs = [toRev(r) for r in results] - builders = {} - - for r in revs: - for b in r['builds']: - builders[b['builder']] = builderInfo(b['builder']) - - for r in revs: - have = set(x['builder'] for x in r['builds']) - need = set(builders.keys()).difference(have) - for n in need: - r['builds'].append({'builder': n, 'log':'', 'ok': False}) - r['builds'].sort(cmp = byBuilder) - - builders = list(builders.items()) - builders.sort() - values = {"revs": revs, "builders": [v for k,v in builders]} - - values['num'] = num - values['prev'] = page - 1 - if len(results) == num: - values['next'] = page + 1 - - path = os.path.join(os.path.dirname(__file__), 'main.html') - self.response.out.write(template.render(path, values)) - -# A DashboardHandler is a webapp.RequestHandler but provides -# authenticated_post - called by post after authenticating -# json - writes object in json format to response output -class DashboardHandler(webapp.RequestHandler): - def post(self): - if not auth(self.request): - self.response.set_status(403) - return - self.authenticated_post() - - def authenticated_post(self): - return - - def json(self, obj): - self.response.set_status(200) - simplejson.dump(obj, self.response.out) - return - -# Todo serves /todo. It tells the builder which commits need to be built. -class Todo(DashboardHandler): - def get(self): - builder = self.request.get('builder') - key = 'todo-%s' % builder - response = memcache.get(key) - if response is None: - # Fell out of memcache. Rebuild from datastore results. - # We walk the commit list looking for nodes that have not - # been built by this builder. - q = Commit.all() - q.order('-__key__') - todo = [] - first = None - for c in q.fetch(N+1): - if first is None: - first = c - if not built(c, builder): - todo.append({'Hash': c.node}) - response = simplejson.dumps(todo) - memcache.set(key, response, 3600) - self.response.set_status(200) - self.response.out.write(response) - -def built(c, builder): - for b in c.builds: - if b.startswith(builder+'`'): - return True - return False - -# Log serves /log/. It retrieves log data by content hash. -class LogHandler(DashboardHandler): - def get(self): - self.response.headers['Content-Type'] = 'text/plain; charset=utf-8' - hash = self.request.path[5:] - l = CompressedLog.get_by_key_name(hash) - if l is None: - self.response.set_status(404) - return - log = bz2.decompress(l.log) - self.response.set_status(200) - self.response.out.write(log) - -# Init creates the commit with id 0. Since this commit doesn't have a parent, -# it cannot be created by Build. -class Init(DashboardHandler): - def authenticated_post(self): - date = parseDate(self.request.get('date')) - node = self.request.get('node') - if not validNode(node) or date is None: - logging.error("Not valid node ('%s') or bad date (%s %s)", node, date, self.request.get('date')) - self.response.set_status(500) - return - - commit = Commit(key_name = '00000000-%s' % node) - commit.num = 0 - commit.node = node - commit.parentnode = '' - commit.user = self.request.get('user') - commit.date = date - commit.desc = self.request.get('desc').encode('utf8') - - commit.put() - - self.response.set_status(200) - -# The last commit when we switched to using entity groups. -# This is the root of the new commit entity group. -RootCommitKeyName = '00000f26-f32c6f1038207c55d5780231f7484f311020747e' - -# CommitHandler serves /commit. -# A GET of /commit retrieves information about the specified commit. -# A POST of /commit creates a node for the given commit. -# If the commit already exists, the POST silently succeeds (like mkdir -p). -class CommitHandler(DashboardHandler): - def get(self): - node = self.request.get('node') - if not validNode(node): - return self.json({'Status': 'FAIL', 'Error': 'malformed node hash'}) - n = nodeByHash(node) - if n is None: - return self.json({'Status': 'FAIL', 'Error': 'unknown revision'}) - return self.json({'Status': 'OK', 'Node': nodeObj(n)}) - - def authenticated_post(self): - # Require auth with the master key, not a per-builder key. - if self.request.get('builder'): - self.response.set_status(403) - return - - node = self.request.get('node') - date = parseDate(self.request.get('date')) - user = self.request.get('user') - desc = self.request.get('desc').encode('utf8') - parenthash = self.request.get('parent') - - if not validNode(node) or not validNode(parenthash) or date is None: - return self.json({'Status': 'FAIL', 'Error': 'malformed node, parent, or date'}) - - n = nodeByHash(node) - if n is None: - p = nodeByHash(parenthash) - if p is None: - return self.json({'Status': 'FAIL', 'Error': 'unknown parent'}) - - # Want to create new node in a transaction so that multiple - # requests creating it do not collide and so that multiple requests - # creating different nodes get different sequence numbers. - # All queries within a transaction must include an ancestor, - # but the original datastore objects we used for the dashboard - # have no common ancestor. Instead, we use a well-known - # root node - the last one before we switched to entity groups - - # as the as the common ancestor. - root = Commit.get_by_key_name(RootCommitKeyName) - - def add_commit(): - if nodeByHash(node, ancestor=root) is not None: - return - - # Determine number for this commit. - # Once we have created one new entry it will be lastRooted.num+1, - # but the very first commit created in this scheme will have to use - # last.num's number instead (last is likely not rooted). - q = Commit.all() - q.order('-__key__') - q.ancestor(root) - last = q.fetch(1)[0] - num = last.num+1 - - n = Commit(key_name = '%08x-%s' % (num, node), parent = root) - n.num = num - n.node = node - n.parentnode = parenthash - n.user = user - n.date = date - n.desc = desc - n.put() - db.run_in_transaction(add_commit) - n = nodeByHash(node) - if n is None: - return self.json({'Status': 'FAIL', 'Error': 'failed to create commit node'}) - - return self.json({'Status': 'OK', 'Node': nodeObj(n)}) - -# Build serves /build. -# A POST to /build records a new build result. -class Build(webapp.RequestHandler): - def post(self): - if not auth(self.request): - self.response.set_status(403) - return - - builder = self.request.get('builder') - log = self.request.get('log').encode('utf8') - - loghash = '' - if len(log) > 0: - loghash = hashlib.sha256(log).hexdigest() - l = CompressedLog(key_name=loghash) - l.log = bz2.compress(log) - l.put() - - node = self.request.get('node') - if not validNode(node): - logging.error('Invalid node %s' % (node)) - self.response.set_status(500) - return - - n = nodeByHash(node) - if n is None: - logging.error('Cannot find node %s' % (node)) - self.response.set_status(404) - return - nn = n - - def add_build(): - n = nodeByHash(node, ancestor=nn) - if n is None: - logging.error('Cannot find hash in add_build: %s %s' % (builder, node)) - return - - s = '%s`%s' % (builder, loghash) - for i, b in enumerate(n.builds): - if b.split('`', 1)[0] == builder: - # logging.error('Found result for %s %s already' % (builder, node)) - n.builds[i] = s - break - else: - # logging.error('Added result for %s %s' % (builder, node)) - n.builds.append(s) - n.put() - - db.run_in_transaction(add_build) - - key = 'todo-%s' % builder - memcache.delete(key) - - c = getBrokenCommit(node, builder) - if c is not None and not c.fail_notification_sent: - notifyBroken(c, builder) - - self.response.set_status(200) - - -def getBrokenCommit(node, builder): - """ - getBrokenCommit returns a Commit that breaks the build. - The Commit will be either the one specified by node or the one after. - """ - - # Squelch mail if already fixed. - head = firstResult(builder) - if broken(head, builder) == False: - return - - # Get current node and node before, after. - cur = nodeByHash(node) - if cur is None: - return - before = nodeBefore(cur) - after = nodeAfter(cur) - - if broken(before, builder) == False and broken(cur, builder): - return cur - if broken(cur, builder) == False and broken(after, builder): - return after - - return - -def firstResult(builder): - q = Commit.all().order('-__key__') - for c in q.fetch(20): - for i, b in enumerate(c.builds): - p = b.split('`', 1) - if p[0] == builder: - return c - return None - -def nodeBefore(c): - return nodeByHash(c.parentnode) - -def nodeAfter(c): - return Commit.all().filter('parenthash', c.node).get() - -def notifyBroken(c, builder): - def send(): - n = Commit.get(c.key()) - if n is None: - logging.error("couldn't retrieve Commit '%s'" % c.key()) - return False - if n.fail_notification_sent: - return False - n.fail_notification_sent = True - return n.put() - if not db.run_in_transaction(send): - return - - subject = const.mail_fail_subject % (builder, c.desc.split('\n')[0]) - path = os.path.join(os.path.dirname(__file__), 'fail-notify.txt') - body = template.render(path, { - "builder": builder, - "node": c.node, - "user": c.user, - "desc": c.desc, - "loghash": logHash(c, builder) - }) - mail.send_mail( - sender=const.mail_from, - to=const.mail_fail_to, - subject=subject, - body=body - ) - -def logHash(c, builder): - for i, b in enumerate(c.builds): - p = b.split('`', 1) - if p[0] == builder: - return p[1] - return "" - -def broken(c, builder): - """ - broken returns True if commit c breaks the build for the specified builder, - False if it is a good build, and None if no results exist for this builder. - """ - if c is None: - return None - for i, b in enumerate(c.builds): - p = b.split('`', 1) - if p[0] == builder: - return len(p[1]) > 0 - return None - -def node(num): - q = Commit.all() - q.filter('num =', num) - n = q.get() - return n - -def nodeByHash(hash, ancestor=None): - q = Commit.all() - q.filter('node =', hash) - if ancestor is not None: - q.ancestor(ancestor) - n = q.get() - return n - -# nodeObj returns a JSON object (ready to be passed to simplejson.dump) describing node. -def nodeObj(n): - return { - 'Hash': n.node, - 'ParentHash': n.parentnode, - 'User': n.user, - 'Date': n.date.strftime('%Y-%m-%d %H:%M %z'), - 'Desc': n.desc, - } - -class FixedOffset(datetime.tzinfo): - """Fixed offset in minutes east from UTC.""" - - def __init__(self, offset): - self.__offset = datetime.timedelta(seconds = offset) - - def utcoffset(self, dt): - return self.__offset - - def tzname(self, dt): - return None - - def dst(self, dt): - return datetime.timedelta(0) - -def validNode(node): - if len(node) != 40: - return False - for x in node: - o = ord(x) - if (o < ord('0') or o > ord('9')) and (o < ord('a') or o > ord('f')): - return False - return True - -def parseDate(date): - if '-' in date: - (a, offset) = date.split('-', 1) - try: - return datetime.datetime.fromtimestamp(float(a), FixedOffset(0-int(offset))) - except ValueError: - return None - if '+' in date: - (a, offset) = date.split('+', 1) - try: - return datetime.datetime.fromtimestamp(float(a), FixedOffset(int(offset))) - except ValueError: - return None - try: - return datetime.datetime.utcfromtimestamp(float(date)) - except ValueError: - return None - -email_re = re.compile('^[^<]+<([^>]*)>$') - -def toUsername(user): - r = email_re.match(user) - if r is None: - return user - email = r.groups()[0] - return email.replace('@golang.org', '') - -def dateToShortStr(d): - return d.strftime('%a %b %d %H:%M') - -def parseBuild(build): - [builder, logblob] = build.split('`') - return {'builder': builder, 'log': logblob, 'ok': len(logblob) == 0} - -def nodeInfo(c): - return { - "node": c.node, - "user": toUsername(c.user), - "date": dateToShortStr(c.date), - "desc": c.desc, - "shortdesc": c.desc.split('\n', 2)[0] - } - -def toRev(c): - b = nodeInfo(c) - b['builds'] = [parseBuild(build) for build in c.builds] - return b - -def byBuilder(x, y): - return cmp(x['builder'], y['builder']) - -# Give old builders work; otherwise they pound on the web site. -class Hwget(DashboardHandler): - def get(self): - self.response.out.write("8000\n") - -# This is the URL map for the server. The first three entries are public, the -# rest are only used by the builders. -application = webapp.WSGIApplication( - [('/', MainPage), - ('/hw-get', Hwget), - ('/log/.*', LogHandler), - ('/commit', CommitHandler), - ('/init', Init), - ('/todo', Todo), - ('/build', Build), - ], debug=True) - -def main(): - run_wsgi_app(application) - -if __name__ == "__main__": - main() - diff --git a/misc/dashboard/godashboard/main.html b/misc/dashboard/godashboard/main.html index 5390afce6..4cd98d851 100644 --- a/misc/dashboard/godashboard/main.html +++ b/misc/dashboard/godashboard/main.html @@ -6,8 +6,6 @@ </head> <body> - <a id="top"></a> - <ul class="menu"> <li>Build Status</li> <li><a href="/package">Packages</a></li> @@ -18,45 +16,8 @@ <h1>Go Dashboard</h1> <h2>Build Status</h2> - <table class="alternate" cellpadding="0" cellspacing="0"> - <tr> - <th></th> - {% for b in builders %} - <th class="builder">{{b.goos}}<br>{{b.goarch}}<br>{{b.note}}</th> - {% endfor %} - <th></th> - <th></th> - <th></th> - </tr> - - {% for r in revs %} - <tr> - <td class="revision"><span class="hash"><a href="https://code.google.com/p/go/source/detail?r={{r.node}}">{{r.node|slice:":12"}}</a></span></td> - {% for b in r.builds %} - <td class="result"> - {% if b.ok %} - <span class="ok">ok</span> - {% else %} - {% if b.log %} - <a class="fail" href="/log/{{b.log}}">fail</a> - {% else %} - - {% endif %} - {% endif %} - </td> - {% endfor %} + <p class="notice">The build status dashboard has moved to <a href="http://build.golang.org">build.golang.org</a>.</p> - <td class="user">{{r.user|escape}}</td> - <td class="date">{{r.date|escape}}</td> - <td class="desc">{{r.shortdesc|escape}}</td> - </tr> - {% endfor %} - </table> - <div class="paginate"> - <a{% if prev %} href="?n={{num}}&p={{prev}}"{% else %} class="inactive"{% endif %}>prev</a> - <a{% if next %} href="?n={{num}}&p={{next}}"{% else %} class="inactive"{% endif %}>next</a> - <a{% if prev %} href="?n={{num}}&p=1"{% else %} class="inactive"{% endif %}>top</a> - </div> </body> </html> diff --git a/misc/dashboard/godashboard/package.html b/misc/dashboard/godashboard/package.html index 8a9d0a3a0..b688af9e2 100644 --- a/misc/dashboard/godashboard/package.html +++ b/misc/dashboard/godashboard/package.html @@ -20,12 +20,14 @@ may or may not build or be safe to use. </p> +<!-- <p> An "ok" in the <b>build</b> column indicates that the package is <a href="http://golang.org/cmd/goinstall/">goinstallable</a> with the latest <a href="http://golang.org/doc/devel/release.html">release</a> of Go. </p> +--> <p> The <b>info</b> column shows the first paragraph from the @@ -39,7 +41,7 @@ <tr> <td class="time">{{r.last_install|date:"Y-M-d H:i"}}</td> <td class="count">{{r.week_count}}</td> - <td class="ok">{% if r.ok %}<a title="{{r.last_ok|date:"Y-M-d H:i"}}">ok</a>{% else %} {% endif %}</td> +<!-- <td class="ok">{% if r.ok %}<a title="{{r.last_ok|date:"Y-M-d H:i"}}">ok</a>{% else %} {% endif %}</td> --> <td class="path"><a href="{{r.web_url}}">{{r.path}}</a></td> <td class="info">{% if r.info %}{{r.info|escape}}{% endif %}</td> </tr> @@ -53,7 +55,7 @@ <tr> <td class="time">{{r.last_install|date:"Y-M-d H:i"}}</td> <td class="count">{{r.count}}</td> - <td class="ok">{% if r.ok %}<a title="{{r.last_ok|date:"Y-M-d H:i"}}">ok</a>{% else %} {% endif %}</td> +<!-- <td class="ok">{% if r.ok %}<a title="{{r.last_ok|date:"Y-M-d H:i"}}">ok</a>{% else %} {% endif %}</td> --> <td class="path"><a href="{{r.web_url}}">{{r.path}}</a></td> <td class="info">{% if r.info %}{{r.info|escape}}{% endif %}</td> </tr> @@ -67,7 +69,7 @@ <tr> <td class="time">{{r.last_install|date:"Y-M-d H:i"}}</td> <td class="count">{{r.count}}</td> - <td class="ok">{% if r.ok %}<a title="{{r.last_ok|date:"Y-M-d H:i"}}">ok</a>{% else %} {% endif %}</td> +<!-- <td class="ok">{% if r.ok %}<a title="{{r.last_ok|date:"Y-M-d H:i"}}">ok</a>{% else %} {% endif %}</td> --> <td class="path"><a href="{{r.web_url}}">{{r.path}}</a></td> <td class="info">{% if r.info %}{{r.info|escape}}{% endif %}</td> </tr> diff --git a/misc/dashboard/godashboard/static/style.css b/misc/dashboard/godashboard/static/style.css index d6d23b536..0ce583a54 100644 --- a/misc/dashboard/godashboard/static/style.css +++ b/misc/dashboard/godashboard/static/style.css @@ -116,3 +116,14 @@ div.paginate a.inactive { td.time { font-family: monospace; } +.notice { + padding: 10px; + margin: 10px; + border: 2px solid #FF6; + background: #900; + color: white; + text-align: center; +} +.notice a { + color: #FF6; +} |