| package main |
| |
| import ( |
| "encoding/json" |
| "fmt" |
| "net/http" |
| "net/url" |
| "path/filepath" |
| "regexp" |
| "runtime" |
| "sort" |
| "strconv" |
| "text/template" |
| "time" |
| |
| "github.com/gorilla/mux" |
| "github.com/skia-dev/glog" |
| "go.skia.org/infra/go/human" |
| "go.skia.org/infra/go/login" |
| "go.skia.org/infra/go/timer" |
| "go.skia.org/infra/go/util" |
| "go.skia.org/infra/golden/go/blame" |
| "go.skia.org/infra/golden/go/diff" |
| "go.skia.org/infra/golden/go/digesttools" |
| "go.skia.org/infra/golden/go/expstorage" |
| "go.skia.org/infra/golden/go/ignore" |
| "go.skia.org/infra/golden/go/summary" |
| "go.skia.org/infra/golden/go/tally" |
| "go.skia.org/infra/golden/go/types" |
| ptypes "go.skia.org/infra/perf/go/types" |
| ) |
| |
| const ( |
| // Default page size used for pagination. |
| DEFAULT_PAGE_SIZE = 20 |
| |
| // Maximum page size used for pagination. |
| MAX_PAGE_SIZE = 100 |
| ) |
| |
| var ( |
| // indexTemplate is the main index.html page we serve. |
| indexTemplate *template.Template = nil |
| |
| // ignoresTemplate is the page for setting up ignore filters. |
| ignoresTemplate *template.Template = nil |
| |
| // compareTemplate is the page for setting up ignore filters. |
| compareTemplate *template.Template = nil |
| |
| // singleTemplate is the page for viewing a single digest. |
| singleTemplate *template.Template = nil |
| |
| // diffTemplate is the page for viewing a single digest. |
| diffTemplate *template.Template = nil |
| |
| // helpTemplate is the help page. |
| helpTemplate *template.Template = nil |
| |
| // triageLogTemplate renders the triage changes listing. |
| triageLogTemplate *template.Template = nil |
| ) |
| |
| // polyMainHandler is the main page for the Polymer based frontend. |
| func polyMainHandler(w http.ResponseWriter, r *http.Request) { |
| glog.Infof("Poly Main Handler: %q\n", r.URL.Path) |
| w.Header().Set("Content-Type", "text/html") |
| if *local { |
| loadTemplates() |
| } |
| if err := indexTemplate.Execute(w, commonEnv); err != nil { |
| glog.Errorln("Failed to expand template:", err) |
| } |
| } |
| |
| func loadTemplates() { |
| indexTemplate = template.Must(template.ParseFiles( |
| filepath.Join(*resourcesDir, "templates/index.html"), |
| filepath.Join(*resourcesDir, "templates/titlebar.html"), |
| filepath.Join(*resourcesDir, "templates/header.html"), |
| )) |
| ignoresTemplate = template.Must(template.ParseFiles( |
| filepath.Join(*resourcesDir, "templates/ignores.html"), |
| filepath.Join(*resourcesDir, "templates/titlebar.html"), |
| filepath.Join(*resourcesDir, "templates/header.html"), |
| )) |
| compareTemplate = template.Must(template.ParseFiles( |
| filepath.Join(*resourcesDir, "templates/compare.html"), |
| filepath.Join(*resourcesDir, "templates/titlebar.html"), |
| filepath.Join(*resourcesDir, "templates/header.html"), |
| )) |
| |
| singleTemplate = template.Must(template.ParseFiles( |
| filepath.Join(*resourcesDir, "templates/single.html"), |
| filepath.Join(*resourcesDir, "templates/titlebar.html"), |
| filepath.Join(*resourcesDir, "templates/header.html"), |
| )) |
| |
| diffTemplate = template.Must(template.ParseFiles( |
| filepath.Join(*resourcesDir, "templates/diff.html"), |
| filepath.Join(*resourcesDir, "templates/titlebar.html"), |
| filepath.Join(*resourcesDir, "templates/header.html"), |
| )) |
| |
| helpTemplate = template.Must(template.ParseFiles( |
| filepath.Join(*resourcesDir, "templates/help.html"), |
| filepath.Join(*resourcesDir, "templates/titlebar.html"), |
| filepath.Join(*resourcesDir, "templates/header.html"), |
| )) |
| |
| triageLogTemplate = template.Must(template.ParseFiles( |
| filepath.Join(*resourcesDir, "templates/triagelog.html"), |
| filepath.Join(*resourcesDir, "templates/titlebar.html"), |
| filepath.Join(*resourcesDir, "templates/header.html"), |
| )) |
| } |
| |
| type SummarySlice []*summary.Summary |
| |
| func (p SummarySlice) Len() int { return len(p) } |
| func (p SummarySlice) Less(i, j int) bool { return p[i].Untriaged > p[j].Untriaged } |
| func (p SummarySlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } |
| |
| // polyListTestsHandler returns a JSON list with high level information about |
| // each test. |
| // |
| // Takes two query parameters: |
| // include - True if ignored digests should be included. (true, false) |
| // query - A query to restrict the responses to, encoded as a URL encoded paramset. |
| // head - True if only digest that appear at head should be included. |
| // |
| // The return format looks like: |
| // |
| // [ |
| // { |
| // "name": "01-original", |
| // "diameter": 123242, |
| // "untriaged": 2, |
| // "num": 2 |
| // }, |
| // ... |
| // ] |
| // |
| func polyListTestsHandler(w http.ResponseWriter, r *http.Request) { |
| if err := r.ParseForm(); err != nil { |
| util.ReportError(w, r, err, "Failed to parse form data.") |
| return |
| } |
| // If the query only includes source_type parameters, and include==false, then we can just |
| // filter the response from summaries.Get(). If the query is broader than that, or |
| // include==true, then we need to call summaries.CalcSummaries(). |
| if err := r.ParseForm(); err != nil { |
| util.ReportError(w, r, err, "Invalid request.") |
| return |
| } |
| q, err := url.ParseQuery(r.FormValue("query")) |
| if err != nil { |
| util.ReportError(w, r, err, "Invalid query in request.") |
| } |
| _, hasSourceType := q["source_type"] |
| sumSlice := []*summary.Summary{} |
| if r.FormValue("include") == "false" && r.FormValue("head") == "true" && len(q) == 1 && hasSourceType { |
| sumMap := summaries.Get() |
| corpus := q["source_type"] |
| for _, s := range sumMap { |
| if util.In(s.Corpus, corpus) { |
| sumSlice = append(sumSlice, s) |
| } |
| } |
| } else { |
| glog.Infof("%q %q %q", r.FormValue("query"), r.FormValue("include"), r.FormValue("head")) |
| sumMap, err := summaries.CalcSummaries(nil, r.FormValue("query"), r.FormValue("include") == "true", r.FormValue("head") == "true") |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to calculate summaries.") |
| } |
| for _, s := range sumMap { |
| sumSlice = append(sumSlice, s) |
| } |
| } |
| sort.Sort(SummarySlice(sumSlice)) |
| w.Header().Set("Content-Type", "application/json") |
| enc := json.NewEncoder(w) |
| if err := enc.Encode(sumSlice); err != nil { |
| util.ReportError(w, r, err, "Failed to encode result") |
| } |
| } |
| |
| // polyTestStatusHandler returns the status of the requested test. |
| func polyTestStatusHandler(w http.ResponseWriter, r *http.Request) { |
| test := mux.Vars(r)["test"] |
| var summary *summary.Summary |
| var ok bool |
| if summary, ok = summaries.Get()[test]; !ok { |
| util.ReportError(w, r, fmt.Errorf("Unknown test: %q", test), "No summaries for test.") |
| } |
| |
| w.Header().Set("Content-Type", "application/json") |
| enc := json.NewEncoder(w) |
| if err := enc.Encode(summary); err != nil { |
| util.ReportError(w, r, err, "Failed to encode result") |
| } |
| } |
| |
| // polyIgnoresJSONHandler returns the current ignore rules in JSON f ormat. |
| func polyIgnoresJSONHandler(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
| |
| ignores := []*ignore.IgnoreRule{} |
| var err error |
| if *startAnalyzer { |
| ignores, err = analyzer.ListIgnoreRules() |
| } else { |
| ignores, err = storages.IgnoreStore.List() |
| } |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to retrieve ignored traces.") |
| } |
| |
| // TODO(stephana): Wrap in response envelope if it makes sense ! |
| enc := json.NewEncoder(w) |
| if err := enc.Encode(ignores); err != nil { |
| util.ReportError(w, r, err, "Failed to encode result") |
| } |
| } |
| |
| func polyIgnoresUpdateHandler(w http.ResponseWriter, r *http.Request) { |
| user := login.LoggedInAs(r) |
| if user == "" { |
| util.ReportError(w, r, fmt.Errorf("Not logged in."), "You must be logged in to update an ignore rule.") |
| return |
| } |
| id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 0) |
| if err != nil { |
| util.ReportError(w, r, err, "ID must be valid integer.") |
| return |
| } |
| req := &IgnoresRequest{} |
| if err := parseJson(r, req); err != nil { |
| util.ReportError(w, r, err, "Failed to parse submitted data.") |
| return |
| } |
| if req.Filter == "" { |
| util.ReportError(w, r, fmt.Errorf("Invalid Filter: %q", req.Filter), "Filters can't be empty.") |
| return |
| } |
| d, err := human.ParseDuration(req.Duration) |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to parse duration") |
| return |
| } |
| ignoreRule := ignore.NewIgnoreRule(user, time.Now().Add(d), req.Filter, req.Note) |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to create ignore rule.") |
| return |
| } |
| ignoreRule.ID = int(id) |
| |
| if *startAnalyzer { |
| err = analyzer.UpdateIgnoreRule(int(id), ignoreRule) |
| } else { |
| err = storages.IgnoreStore.Update(int(id), ignoreRule) |
| } |
| |
| if err != nil { |
| util.ReportError(w, r, err, "Unable to update ignore rule.") |
| } else { |
| // If update worked just list the current ignores and return them. |
| polyIgnoresJSONHandler(w, r) |
| } |
| } |
| |
| func polyIgnoresDeleteHandler(w http.ResponseWriter, r *http.Request) { |
| user := login.LoggedInAs(r) |
| if user == "" { |
| util.ReportError(w, r, fmt.Errorf("Not logged in."), "You must be logged in to add an ignore rule.") |
| return |
| } |
| id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 0) |
| if err != nil { |
| util.ReportError(w, r, err, "ID must be valid integer.") |
| return |
| } |
| |
| if *startAnalyzer { |
| err = analyzer.DeleteIgnoreRule(int(id), user) |
| } else { |
| _, err = storages.IgnoreStore.Delete(int(id), user) |
| } |
| |
| if err != nil { |
| util.ReportError(w, r, err, "Unable to delete ignore rule.") |
| } else { |
| // If delete worked just list the current ignores and return them. |
| polyIgnoresJSONHandler(w, r) |
| } |
| } |
| |
| type IgnoresRequest struct { |
| Duration string `json:"duration"` |
| Filter string `json:"filter"` |
| Note string `json:"note"` |
| } |
| |
| var durationRe = regexp.MustCompile("([0-9]+)([smhdw])") |
| |
| // polyIgnoresAddHandler is for adding a new ignore rule. |
| func polyIgnoresAddHandler(w http.ResponseWriter, r *http.Request) { |
| user := login.LoggedInAs(r) |
| if user == "" { |
| util.ReportError(w, r, fmt.Errorf("Not logged in."), "You must be logged in to add an ignore rule.") |
| return |
| } |
| req := &IgnoresRequest{} |
| if err := parseJson(r, req); err != nil { |
| util.ReportError(w, r, err, "Failed to parse submitted data.") |
| return |
| } |
| if req.Filter == "" { |
| util.ReportError(w, r, fmt.Errorf("Invalid Filter: %q", req.Filter), "Filters can't be empty.") |
| return |
| } |
| d, err := human.ParseDuration(req.Duration) |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to parse duration") |
| return |
| } |
| ignoreRule := ignore.NewIgnoreRule(user, time.Now().Add(d), req.Filter, req.Note) |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to create ignore rule.") |
| return |
| } |
| |
| if *startAnalyzer { |
| err = analyzer.AddIgnoreRule(ignoreRule) |
| } else { |
| err = storages.IgnoreStore.Create(ignoreRule) |
| } |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to create ignore rule.") |
| return |
| } |
| |
| polyIgnoresJSONHandler(w, r) |
| } |
| |
| // polyIgnoresHandler is for setting up ignores rules. |
| func polyIgnoresHandler(w http.ResponseWriter, r *http.Request) { |
| glog.Infof("Poly Ignores Handler: %q\n", r.URL.Path) |
| w.Header().Set("Content-Type", "text/html") |
| if *local { |
| loadTemplates() |
| } |
| if err := ignoresTemplate.Execute(w, commonEnv); err != nil { |
| glog.Errorln("Failed to expand template:", err) |
| } |
| } |
| |
| // polySingleDigestHandler is a page about a single digest. |
| func polySingleDigestHandler(w http.ResponseWriter, r *http.Request) { |
| glog.Infof("Poly Single Digest Handler: %q\n", r.URL.Path) |
| w.Header().Set("Content-Type", "text/html") |
| if *local { |
| loadTemplates() |
| } |
| if err := singleTemplate.Execute(w, commonEnv); err != nil { |
| glog.Errorln("Failed to expand template:", err) |
| } |
| } |
| |
| // polyDiffDigestHandler is a page about the differences between two digests. |
| func polyDiffDigestHandler(w http.ResponseWriter, r *http.Request) { |
| glog.Infof("Poly Diff Digest Handler: %q\n", r.URL.Path) |
| w.Header().Set("Content-Type", "text/html") |
| if *local { |
| loadTemplates() |
| } |
| if err := diffTemplate.Execute(w, commonEnv); err != nil { |
| glog.Errorln("Failed to expand template:", err) |
| } |
| } |
| |
| // polyDiffJSONDigestHandler takes three parameters (top, left, and test), and |
| // returns a JSON serialized PolyTestDiffInfo as the response. |
| func polyDiffJSONDigestHandler(w http.ResponseWriter, r *http.Request) { |
| if err := r.ParseForm(); err != nil { |
| util.ReportError(w, r, err, "Failed to parse form values") |
| return |
| } |
| top := r.Form.Get("top") |
| left := r.Form.Get("left") |
| test := r.Form.Get("test") |
| if top == "" || left == "" || test == "" { |
| util.ReportError(w, r, fmt.Errorf("Some query parameters are missing: %q %q %q", top, left, test), "Missing query parameters.") |
| return |
| } |
| |
| diffs, err := storages.DiffStore.Get(left, []string{top}) |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to do diffs") |
| return |
| } |
| full := storages.DiffStore.AbsPath([]string{top, left}) |
| d := diffs[top] |
| ret := PolyTestDiffInfo{ |
| Test: test, |
| TopDigest: top, |
| LeftDigest: left, |
| NumDiffPixels: d.NumDiffPixels, |
| PixelDiffPercent: d.PixelDiffPercent, |
| MaxRGBADiffs: d.MaxRGBADiffs, |
| DiffImg: pathToURLConverter(d.PixelDiffFilePath), |
| TopImg: pathToURLConverter(full[top]), |
| LeftImg: pathToURLConverter(full[left]), |
| } |
| |
| w.Header().Set("Content-Type", "application/json") |
| enc := json.NewEncoder(w) |
| if err := enc.Encode(ret); err != nil { |
| util.ReportError(w, r, err, "Failed to encode result") |
| } |
| } |
| |
| // polyHelpHandler is for serving the main compare page. |
| func polyHelpHandler(w http.ResponseWriter, r *http.Request) { |
| glog.Infof("Poly Help Handler: %q\n", r.URL.Path) |
| |
| w.Header().Set("Content-Type", "text/html") |
| if *local { |
| loadTemplates() |
| } |
| if err := helpTemplate.Execute(w, commonEnv); err != nil { |
| glog.Errorln("Failed to expand template:", err) |
| } |
| } |
| |
| // polyCompareHandler is for serving the main compare page. |
| func polyCompareHandler(w http.ResponseWriter, r *http.Request) { |
| glog.Infof("Poly Compare Handler: %q\n", r.URL.Path) |
| w.Header().Set("Content-Type", "text/html") |
| if *local { |
| loadTemplates() |
| } |
| if err := compareTemplate.Execute(w, commonEnv); err != nil { |
| glog.Errorln("Failed to expand template:", err) |
| } |
| } |
| |
| // polyTriageLogView is for serving the main triage log page. |
| func polyTriageLogView(w http.ResponseWriter, r *http.Request) { |
| glog.Infof("Poly Triagelog Handler: %q\n", r.URL.Path) |
| w.Header().Set("Content-Type", "text/html") |
| if *local { |
| loadTemplates() |
| } |
| if err := triageLogTemplate.Execute(w, commonEnv); err != nil { |
| glog.Errorln("Failed to expand template:", err) |
| } |
| } |
| |
| // polyTriageLogHandler returns the entries in the triagelog paginated |
| // in reverse chronological order. |
| func polyTriageLogHandler(w http.ResponseWriter, r *http.Request) { |
| // Get the pagination params. |
| var logEntries []*expstorage.TriageLogEntry |
| var total int |
| |
| q := r.URL.Query() |
| offset, size, err := util.PaginationParams(q, 0, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE) |
| if err == nil { |
| details := q.Get("details") == "true" |
| logEntries, total, err = storages.ExpectationsStore.QueryLog(offset, size, details) |
| } |
| |
| if err != nil { |
| util.ReportError(w, r, err, "Unable to retrieve triage log.") |
| return |
| } |
| |
| pagination := &util.ResponsePagination{ |
| Offset: offset, |
| Size: size, |
| Total: total, |
| } |
| |
| sendResponse(w, logEntries, http.StatusOK, pagination) |
| } |
| |
| // PolyTestRequest is the POST'd request body handled by polyTestHandler. |
| type PolyTestRequest struct { |
| Test string `json:"test"` |
| TopFilter string `json:"topFilter"` |
| LeftFilter string `json:"LeftFilter"` |
| TopQuery string `json:"topQuery"` |
| LeftQuery string `json:"leftQuery"` |
| TopIncludeIgnores bool `json:"topIncludeIgnores"` |
| LeftIncludeIgnores bool `json:"leftIncludeIgnores"` |
| TopN int `json:"topN"` |
| LeftN int `json:"leftN"` |
| Sort string `json:"sort"` // Which side to sort, "top" or "left". |
| Dir string `json:"dir"` // Direction to sort, ["", "asc", "desc"] |
| Digest string `json:"digest"` // The digest to sort against. |
| Head bool `json:"head"` // If true only return digests at head. |
| } |
| |
| // PolyTestImgInfo info about a single source digest. Used in PolyTestGUI. |
| type PolyTestImgInfo struct { |
| Digest string `json:"digest"` |
| N int `json:"n"` // The number of images with this digest. |
| PixelDiffPercent float32 `json:"diff"` // Diff from the given digest to compare against, otherwise zero. |
| } |
| |
| // PolyTestImgInfoSlice is for sorting slices of PolyTestImgInfo. |
| type PolyTestImgInfoSlice []*PolyTestImgInfo |
| |
| func (p PolyTestImgInfoSlice) Len() int { return len(p) } |
| func (p PolyTestImgInfoSlice) Less(i, j int) bool { |
| if p[i].N != p[j].N { |
| return p[i].N > p[j].N |
| } else { |
| return p[i].Digest > p[j].Digest |
| } |
| } |
| func (p PolyTestImgInfoSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } |
| |
| // PolyTestImgInfoDiffAscSlice is for sorting slices of PolyTestImgInfo by PixelDiffPercent. |
| type PolyTestImgInfoDiffAscSlice []*PolyTestImgInfo |
| |
| func (p PolyTestImgInfoDiffAscSlice) Len() int { return len(p) } |
| func (p PolyTestImgInfoDiffAscSlice) Less(i, j int) bool { |
| if p[i].PixelDiffPercent != p[j].PixelDiffPercent { |
| return p[i].PixelDiffPercent < p[j].PixelDiffPercent |
| } else { |
| return p[i].Digest < p[j].Digest |
| } |
| } |
| func (p PolyTestImgInfoDiffAscSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } |
| |
| // PolyTestImgInfo info about a single diff between two source digests. Used in |
| // PolyTestGUI. |
| type PolyTestDiffInfo struct { |
| Test string `json:"test"` |
| TopDigest string `json:"topDigest"` |
| LeftDigest string `json:"leftDigest"` |
| NumDiffPixels int `json:"numDiffPixels"` |
| PixelDiffPercent float32 `json:"pixelDiffPercent"` |
| MaxRGBADiffs []int `json:"maxRGBADiffs"` |
| DiffImg string `json:"diffImgUrl"` |
| TopImg string `json:"topImgUrl"` |
| LeftImg string `json:"leftImgUrl"` |
| } |
| |
| // PolyTestGUI serialized as JSON is the response body from polyTestHandler. |
| type PolyTestGUI struct { |
| Top []*PolyTestImgInfo `json:"top"` |
| Left []*PolyTestImgInfo `json:"left"` |
| Grid [][]*PolyTestDiffInfo `json:"grid"` |
| Message string `json:"message"` |
| TopTotal int `json:"topTotal"` |
| LeftTotal int `json:"leftTotal"` |
| } |
| |
| // imgInfo returns a populated slice of PolyTestImgInfo based on the filter and |
| // queryString passed in. |
| // |
| // max maybe set to -1, which means to not truncate the response digest slice. |
| // If sortAgainstHash is true then the result will be sorted in direction 'dir' versus the given 'digest', |
| // otherwise the results will be sorted in terms of ascending N. |
| // |
| // If head is true then only return digests that appear at head. |
| func imgInfo(filter, queryString, testName string, e types.TestClassification, max int, includeIgnores bool, sortAgainstHash bool, dir string, digest string, head bool) ([]*PolyTestImgInfo, int, error) { |
| query, err := url.ParseQuery(queryString) |
| if err != nil { |
| return nil, 0, fmt.Errorf("Failed to parse Query in imgInfo: %s", err) |
| } |
| query[types.PRIMARY_KEY_FIELD] = []string{testName} |
| |
| t := timer.New("finding digests") |
| digests := map[string]int{} |
| if head { |
| tile, err := storages.GetLastTileTrimmed(includeIgnores) |
| if err != nil { |
| return nil, 0, fmt.Errorf("Failed to retrieve tallies in imgInfo: %s", err) |
| } |
| lastCommitIndex := tile.LastCommitIndex() |
| for _, tr := range tile.Traces { |
| if ptypes.Matches(tr, query) { |
| for i := lastCommitIndex; i >= 0; i-- { |
| if tr.IsMissing(i) { |
| continue |
| } else { |
| digests[tr.(*ptypes.GoldenTrace).Values[i]] = 1 |
| break |
| } |
| } |
| } |
| } |
| } else { |
| digests, err = tallies.ByQuery(query, includeIgnores) |
| if err != nil { |
| return nil, 0, fmt.Errorf("Failed to retrieve tallies in imgInfo: %s", err) |
| } |
| } |
| glog.Infof("Num Digests: %d", len(digests)) |
| t.Stop() |
| |
| // If we are going to sort against a digest then we need to calculate |
| // the diff metrics against that digest. |
| diffMetrics := map[string]*diff.DiffMetrics{} |
| if sortAgainstHash { |
| digestSlice := make([]string, len(digests)) |
| for d, _ := range digests { |
| digestSlice = append(digestSlice, d) |
| } |
| var err error |
| diffMetrics, err = storages.DiffStore.Get(digest, digestSlice) |
| if err != nil { |
| return nil, 0, fmt.Errorf("Failed to calculate diffs to sort against: %s", err) |
| } |
| } |
| |
| label := types.LabelFromString(filter) |
| // Now filter digests by their expectations status here. |
| t = timer.New("apply expectations") |
| ret := []*PolyTestImgInfo{} |
| for digest, n := range digests { |
| if e[digest] != label { |
| continue |
| } |
| p := &PolyTestImgInfo{ |
| Digest: digest, |
| N: n, |
| } |
| if sortAgainstHash { |
| p.PixelDiffPercent = diffMetrics[digest].PixelDiffPercent |
| } |
| ret = append(ret, p) |
| } |
| t.Stop() |
| |
| if sortAgainstHash { |
| if dir == "asc" { |
| sort.Sort(PolyTestImgInfoDiffAscSlice(ret)) |
| } else { |
| sort.Sort(sort.Reverse(PolyTestImgInfoDiffAscSlice(ret))) |
| } |
| } else { |
| sort.Sort(PolyTestImgInfoSlice(ret)) |
| } |
| |
| total := len(ret) |
| if max > 0 && len(ret) > max { |
| ret = ret[:max] |
| } |
| return ret, total, nil |
| } |
| |
| // polyTestHandler returns a JSON description for the given test. |
| // |
| // Takes an JSON encoded POST body of the following form: |
| // |
| // { |
| // test: The name of the test. |
| // topFilter=["positive", "negative", "untriaged"] |
| // leftFilter=["positive", "negative", "untriaged"] |
| // topQuery: "", |
| // leftQuery: "", |
| // topIncludeIgnores: bool, |
| // leftIncludeIgnores: bool, |
| // topN: topN, |
| // leftN: leftN, |
| // head: [true, false], |
| // } |
| // |
| // |
| // The return format looks like: |
| // |
| // { |
| // "top": [img1, img2, ...] |
| // "left": [imgA, imgB, ...] |
| // "grid": [ |
| // [diff1A, diff2A, ...], |
| // [diff1B, diff2B, ...], |
| // ], |
| // info: "Could be error or warning.", |
| // } |
| // |
| // Where imgN is serialized PolyTestImgInfo, and |
| // diffN is a serialized PolyTestDiffInfo struct. |
| // Note that this format is what res/imp/grid expects to |
| // receive. |
| // |
| func polyTestHandler(w http.ResponseWriter, r *http.Request) { |
| req := &PolyTestRequest{} |
| if err := parseJson(r, req); err != nil { |
| util.ReportError(w, r, err, "Failed to parse JSON request.") |
| return |
| } |
| exp, err := storages.ExpectationsStore.Get() |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to load expectations.") |
| return |
| } |
| e := exp.Tests[req.Test] |
| |
| topDigests, topTotal, err := imgInfo(req.TopFilter, req.TopQuery, req.Test, e, req.TopN, req.TopIncludeIgnores, req.Sort == "top", req.Dir, req.Digest, req.Head) |
| leftDigests, leftTotal, err := imgInfo(req.LeftFilter, req.LeftQuery, req.Test, e, req.LeftN, req.LeftIncludeIgnores, req.Sort == "left", req.Dir, req.Digest, req.Head) |
| |
| // Extract out string slices of digests to pass to *AbsPath and storages.DiffStore.Get(). |
| allDigests := map[string]bool{} |
| topDigestMap := map[string]bool{} |
| for _, d := range topDigests { |
| allDigests[d.Digest] = true |
| topDigestMap[d.Digest] = true |
| } |
| for _, d := range leftDigests { |
| allDigests[d.Digest] = true |
| } |
| |
| topDigestSlice := util.KeysOfStringSet(topDigestMap) |
| allDigestsSlice := util.KeysOfStringSet(allDigests) |
| full := storages.DiffStore.AbsPath(allDigestsSlice) |
| |
| grid := [][]*PolyTestDiffInfo{} |
| for _, l := range leftDigests { |
| row := []*PolyTestDiffInfo{} |
| diffs, err := storages.DiffStore.Get(l.Digest, topDigestSlice) |
| if err != nil { |
| glog.Errorf("Failed to do diffs: %s", err) |
| continue |
| } |
| for _, t := range topDigests { |
| d, ok := diffs[t.Digest] |
| if !ok { |
| glog.Errorf("Failed to find expected diff for: %s", t.Digest) |
| d = &diff.DiffMetrics{ |
| MaxRGBADiffs: []int{}, |
| } |
| } |
| row = append(row, &PolyTestDiffInfo{ |
| Test: req.Test, |
| TopDigest: t.Digest, |
| LeftDigest: l.Digest, |
| NumDiffPixels: d.NumDiffPixels, |
| PixelDiffPercent: d.PixelDiffPercent, |
| MaxRGBADiffs: d.MaxRGBADiffs, |
| DiffImg: pathToURLConverter(d.PixelDiffFilePath), |
| TopImg: pathToURLConverter(full[t.Digest]), |
| LeftImg: pathToURLConverter(full[l.Digest]), |
| }) |
| } |
| grid = append(grid, row) |
| } |
| |
| p := PolyTestGUI{ |
| Top: topDigests, |
| Left: leftDigests, |
| Grid: grid, |
| TopTotal: topTotal, |
| LeftTotal: leftTotal, |
| } |
| if len(p.Top) == 0 || len(p.Left) == 0 { |
| p.Message = "Failed to find images that match those filters." |
| } |
| w.Header().Set("Content-Type", "application/json") |
| enc := json.NewEncoder(w) |
| if err := enc.Encode(p); err != nil { |
| util.ReportError(w, r, err, "Failed to encode result") |
| } |
| } |
| |
| // PolyTriageRequest is the form of the JSON posted to polyTriageHandler. |
| type PolyTriageRequest struct { |
| Test string `json:"test"` |
| Digest []string `json:"digest"` |
| Status string `json:"status"` |
| All bool `json:"all"` // Ignore Digest and instead use the query, filter, and include. |
| Query string `json:"query"` |
| Filter string `json:"filter"` |
| Include bool `json:"include"` // Include ignored digests. |
| Head bool `json:"head"` // Only include digests at head if true. |
| } |
| |
| // polyTriageHandler handles a request to change the triage status of one or more |
| // digests of one test. |
| // |
| // It accepts a POST'd JSON serialization of PolyTriageRequest and updates |
| // the expectations. |
| func polyTriageHandler(w http.ResponseWriter, r *http.Request) { |
| req := &PolyTriageRequest{} |
| if err := parseJson(r, req); err != nil { |
| util.ReportError(w, r, err, "Failed to parse JSON request.") |
| return |
| } |
| glog.Infof("Triage request: %#v", req) |
| user := login.LoggedInAs(r) |
| if user == "" { |
| util.ReportError(w, r, fmt.Errorf("Not logged in."), "You must be logged in to triage.") |
| return |
| } |
| |
| // Build the expecations change request from the list of digests passed in. |
| digests := req.Digest |
| |
| // Or build the expectations change request from filter, query, and include. |
| if req.All { |
| exp, err := storages.ExpectationsStore.Get() |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to load expectations.") |
| return |
| } |
| e := exp.Tests[req.Test] |
| ii, _, err := imgInfo(req.Filter, req.Query, req.Test, e, -1, req.Include, false, "", "", req.Head) |
| digests = []string{} |
| for _, d := range ii { |
| digests = append(digests, d.Digest) |
| } |
| } |
| |
| // Label the digests. |
| labelledDigests := map[string]types.Label{} |
| for _, d := range digests { |
| labelledDigests[d] = types.LabelFromString(req.Status) |
| } |
| |
| tc := map[string]types.TestClassification{ |
| req.Test: labelledDigests, |
| } |
| // If the analyzer is running then use that to update the expectations. |
| if *startAnalyzer { |
| _, err := analyzer.SetDigestLabels(tc, user) |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to set the expectations.") |
| return |
| } |
| } else { |
| // Otherwise update the expectations directly. |
| if err := storages.ExpectationsStore.AddChange(tc, user); err != nil { |
| util.ReportError(w, r, err, "Failed to store the updated expectations.") |
| return |
| } |
| } |
| w.Header().Set("Content-Type", "application/json") |
| enc := json.NewEncoder(w) |
| if err := enc.Encode(map[string]string{}); err != nil { |
| util.ReportError(w, r, err, "Failed to encode result") |
| } |
| } |
| |
| func safeGet(paramset map[string][]string, key string) []string { |
| if ret, ok := paramset[key]; ok { |
| sort.Strings(ret) |
| return ret |
| } else { |
| return []string{} |
| } |
| } |
| |
| // PerParamCompare is used in PolyDetailsGUI |
| type PerParamCompare struct { |
| Name string `json:"name"` // Name of the parameter. |
| Top []string `json:"top"` // All the parameter values that appear for the top digest. |
| Left []string `json:"left"` // All the parameter values that appear for the left digest. |
| } |
| |
| // Point is a single point. Used in Plot. |
| type Point struct { |
| X int `json:"x"` // The commit index [0-49]. |
| Y int `json:"y"` |
| S int `json:"s"` // Status of the digest: 0 if the digest matches our search, 1-8 otherwise. |
| } |
| |
| type PointSlice []Point |
| |
| func (p PointSlice) Len() int { return len(p) } |
| func (p PointSlice) Less(i, j int) bool { return p[i].X < p[j].X } |
| func (p PointSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } |
| |
| // Trace represents a single test over time. |
| type Trace struct { |
| Data []Point `json:"data"` // One Point for each test result. |
| Label string `json:"label"` // The id of the trace. |
| Params map[string]string `json:"params"` |
| } |
| |
| // DigestStatus is a digest and its status, used in PolyDetailsGUI. |
| type DigestStatus struct { |
| Digest string `json:"digest"` |
| Status string `json:"status"` |
| } |
| |
| // PolyDetailsGUI is used in the JSON returned from polyDetailsHandler. It |
| // represents the known information about a single digest for a given test. |
| type PolyDetailsGUI struct { |
| TopStatus string `json:"topStatus"` |
| LeftStatus string `json:"leftStatus"` |
| Params []*PerParamCompare `json:"params"` |
| Traces []*Trace `json:"traces"` |
| Commits []*ptypes.Commit `json:"commits"` |
| OtherDigests []*DigestStatus `json:"otherDigests"` |
| TileSize int `json:"tileSize"` |
| PosClosest *digesttools.Closest `json:"posClosest"` |
| NegClosest *digesttools.Closest `json:"negClosest"` |
| Blame *blame.BlameDistribution `json:"blame"` |
| } |
| |
| // polyDetailsHandler handles requests about individual digests in a test. |
| // |
| // It expects a request with the following query parameters: |
| // |
| // test - The name of the test. |
| // top - A digest in the test. |
| // left - A digest in the test. |
| // graphs - Boolean that's true if graph data should be returned. |
| // closest - Boolean that's true if the closest positive and negative digests should be returned. |
| // |
| // The response looks like: |
| // { |
| // topStatus: "untriaged", |
| // leftStatus: "positive", |
| // params: [ |
| // { |
| // "name": "config", |
| // "top" : ["8888", "565"], |
| // "left": ["gpu"], |
| // }, |
| // ... |
| // ], |
| // traces: [ |
| // { |
| // data: {x: 1, y: 1, s: true}, {x: 5, y: 1, s: true}, |
| // label: "key1", |
| // }, |
| // ... |
| // ] |
| // } |
| // |
| // TODO(jcgregorio) Add unit tests. |
| func polyDetailsHandler(w http.ResponseWriter, r *http.Request) { |
| tile, err := storages.GetLastTileTrimmed(true) |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to load tile") |
| return |
| } |
| if err := r.ParseForm(); err != nil { |
| util.ReportError(w, r, err, "Failed to parse form values") |
| return |
| } |
| top := r.Form.Get("top") |
| left := r.Form.Get("left") |
| if top == "" || left == "" { |
| util.ReportError(w, r, fmt.Errorf("Missing the top or left query parameter: %s %s", top, left), "No digests specified.") |
| return |
| } |
| test := r.Form.Get("test") |
| if test == "" { |
| util.ReportError(w, r, fmt.Errorf("Missing the test query parameter."), "No test name specified.") |
| return |
| } |
| exp, err := storages.ExpectationsStore.Get() |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to load expectations.") |
| return |
| } |
| |
| ret := PolyDetailsGUI{ |
| TopStatus: exp.Classification(test, top).String(), |
| LeftStatus: exp.Classification(test, left).String(), |
| Params: []*PerParamCompare{}, |
| Traces: []*Trace{}, |
| TileSize: len(tile.Commits), |
| } |
| |
| topParamSet := map[string][]string{} |
| leftParamSet := map[string][]string{} |
| |
| // Now build out the ParamSet for each digest. |
| tally := tallies.ByTrace() |
| traceNames := []string{} |
| for id, tr := range tile.Traces { |
| traceTally, ok := tally[id] |
| if !ok { |
| continue |
| } |
| if tr.Params()[types.PRIMARY_KEY_FIELD] == test { |
| if _, ok := (*traceTally)[top]; ok { |
| topParamSet = util.AddParamsToParamSet(topParamSet, tr.Params()) |
| } |
| if _, ok := (*traceTally)[left]; ok { |
| leftParamSet = util.AddParamsToParamSet(leftParamSet, tr.Params()) |
| } |
| traceNames = append(traceNames, id) |
| } |
| } |
| |
| keys := util.UnionStrings(util.KeysOfParamSet(topParamSet), util.KeysOfParamSet(leftParamSet)) |
| sort.Strings(keys) |
| for _, k := range keys { |
| ret.Params = append(ret.Params, &PerParamCompare{ |
| Name: k, |
| Top: safeGet(topParamSet, k), |
| Left: safeGet(leftParamSet, k), |
| }) |
| } |
| |
| // Now build the trace data. |
| if r.Form.Get("graphs") == "true" { |
| ret.Traces, ret.OtherDigests = buildTraceData(top, traceNames, tile, tally, exp) |
| ret.Commits = tile.Commits |
| ret.Blame = blamer.GetBlame(test, top, ret.Commits) |
| } |
| |
| // Now find the closest positive and negative digests. |
| if r.Form.Get("closest") == "true" { |
| ret.PosClosest = digesttools.ClosestDigest(test, top, exp, storages.DiffStore, types.POSITIVE) |
| ret.NegClosest = digesttools.ClosestDigest(test, top, exp, storages.DiffStore, types.NEGATIVE) |
| } |
| |
| w.Header().Set("Content-Type", "application/json") |
| enc := json.NewEncoder(w) |
| if err := enc.Encode(ret); err != nil { |
| util.ReportError(w, r, err, "Failed to encode result") |
| } |
| } |
| |
| // digestIndex returns the index of the digest d in digestInfo, or -1 if not found. |
| func digestIndex(d string, digestInfo []*DigestStatus) int { |
| for i, di := range digestInfo { |
| if di.Digest == d { |
| return i |
| } |
| } |
| return -1 |
| } |
| |
| // buildTraceData returns a populated []*Trace for all the traces that contain 'digest'. |
| func buildTraceData(digest string, traceNames []string, tile *ptypes.Tile, tally tally.TraceTally, exp *expstorage.Expectations) ([]*Trace, []*DigestStatus) { |
| sort.Strings(traceNames) |
| ret := []*Trace{} |
| last := tile.LastCommitIndex() |
| y := 0 |
| // Keep track of the first 7 non-matching digests we encounter so we can color them differently. |
| otherDigests := []*DigestStatus{} |
| for _, id := range traceNames { |
| traceTally, ok := tally[id] |
| if !ok { |
| continue |
| } |
| if count, ok := (*traceTally)[digest]; !ok || count == 0 { |
| continue |
| } |
| trace := tile.Traces[id].(*ptypes.GoldenTrace) |
| p := &Trace{ |
| Data: []Point{}, |
| Label: id, |
| Params: trace.Params(), |
| } |
| for i := last; i >= 0; i-- { |
| if trace.IsMissing(i) { |
| continue |
| } |
| // s is the status of the digest, it is either 0 for a match, or [1-8] if not. |
| s := 0 |
| if trace.Values[i] != digest { |
| if index := digestIndex(trace.Values[i], otherDigests); index != -1 { |
| s = index + 1 |
| } else { |
| if len(otherDigests) < 8 { |
| d := trace.Values[i] |
| test := trace.Params()[types.PRIMARY_KEY_FIELD] |
| otherDigests = append(otherDigests, &DigestStatus{ |
| Digest: d, |
| Status: exp.Classification(test, d).String(), |
| }) |
| s = len(otherDigests) |
| } else { |
| s = 8 |
| } |
| } |
| } |
| p.Data = append(p.Data, Point{ |
| X: i, |
| Y: y, |
| S: s, |
| }) |
| } |
| sort.Sort(PointSlice(p.Data)) |
| ret = append(ret, p) |
| y += 1 |
| } |
| |
| return ret, otherDigests |
| } |
| |
| func polyParamsHandler(w http.ResponseWriter, r *http.Request) { |
| tile, err := storages.GetLastTileTrimmed(false) |
| if err != nil { |
| util.ReportError(w, r, err, "Failed to load tile") |
| return |
| } |
| w.Header().Set("Content-Type", "application/json") |
| enc := json.NewEncoder(w) |
| if err := enc.Encode(tile.ParamSet); err != nil { |
| util.ReportError(w, r, err, "Failed to encode result") |
| } |
| } |
| |
| // polyAllHashesHandler returns the list of all hashes we currently know about regardless of triage status. |
| // |
| // Endpoint used by the Android buildbots to avoid transferring already known images. |
| func polyAllHashesHandler(w http.ResponseWriter, r *http.Request) { |
| byTest := tallies.ByTest() |
| hashes := map[string]bool{} |
| for _, test := range byTest { |
| for k, _ := range *test { |
| hashes[k] = true |
| } |
| } |
| |
| w.Header().Set("Content-Type", "text/plain") |
| for k, _ := range hashes { |
| if _, err := w.Write([]byte(k)); err != nil { |
| util.ReportError(w, r, err, "Failed to write result.") |
| } |
| if _, err := w.Write([]byte("\n")); err != nil { |
| util.ReportError(w, r, err, "Failed to write result.") |
| } |
| } |
| } |
| |
| // polyStatusHandler returns the current status of with respect to |
| // HEAD. |
| func polyStatusHandler(w http.ResponseWriter, r *http.Request) { |
| sendJsonResponse(w, statusWatcher.GetStatus()) |
| } |
| |
| // sendJsonResponse serializes resp to JSON. If an error occurs |
| // a text based error code is send to the client. |
| func sendJsonResponse(w http.ResponseWriter, resp interface{}) { |
| w.Header().Set("Content-Type", "application/json") |
| if err := json.NewEncoder(w).Encode(resp); err != nil { |
| util.ReportError(w, nil, err, "Failed to encode result") |
| } |
| } |
| |
| // makeResourceHandler creates a static file handler that sets a caching policy. |
| func makeResourceHandler() func(http.ResponseWriter, *http.Request) { |
| fileServer := http.FileServer(http.Dir(*resourcesDir)) |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Add("Cache-Control", string(300)) |
| fileServer.ServeHTTP(w, r) |
| } |
| } |
| |
| // Init figures out where the resources are and then loads the templates. |
| func Init() { |
| if *resourcesDir == "" { |
| _, filename, _, _ := runtime.Caller(0) |
| *resourcesDir = filepath.Join(filepath.Dir(filename), "../..") |
| } |
| loadTemplates() |
| } |