blob: a688b53f8a51ec470633f1861e78eb93063c31c2 [file] [log] [blame]
// Copyright 2021 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package userhtml
import (
"context"
"fmt"
"strings"
"time"
"google.golang.org/grpc/codes"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/grpc/appstatus"
"go.chromium.org/luci/server/caching"
"go.chromium.org/luci/server/caching/layered"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
"go.chromium.org/luci/cv/internal/acls"
"go.chromium.org/luci/cv/internal/changelist"
"go.chromium.org/luci/cv/internal/common"
"go.chromium.org/luci/cv/internal/rpc/pagination"
"go.chromium.org/luci/cv/internal/run"
)
func recentsPage(c *router.Context) {
project := c.Params.ByName("Project")
params, err := parseFormParams(c)
if err != nil {
errPage(c, err)
return
}
runs, prev, next, err := searchRuns(c.Context, project, params)
if err != nil {
errPage(c, err)
return
}
runsWithCLs, err := resolveRunsCLs(c.Context, runs)
if err != nil {
errPage(c, err)
return
}
templates.MustRender(c.Context, c.Writer, "pages/recent_runs.html", map[string]interface{}{
"Runs": runsWithCLs,
"Project": project,
"PrevPage": prev,
"NextPage": next,
"FilterStatus": params.statusString(),
"FilterMode": params.modeString(),
"Now": startTime(c.Context),
})
}
type recentRunsParams struct {
status run.Status
mode run.Mode
pageTokenString string
}
func parseFormParams(c *router.Context) (recentRunsParams, error) {
params := recentRunsParams{}
if err := c.Request.ParseForm(); err != nil {
return params, errors.Annotate(err, "failed to parse form").Err()
}
s := c.Request.Form.Get("status")
switch val, ok := run.Status_value[strings.ToUpper(s)]; {
case s == "":
case ok:
params.status = run.Status(val)
default:
return params, fmt.Errorf("invalid Run status %q", s)
}
switch m := run.Mode(c.Request.Form.Get("mode")); {
case m == "":
case m.Valid():
params.mode = m
default:
return params, fmt.Errorf("invalid Run mode %q", params.mode)
}
params.pageTokenString = strings.TrimSpace(c.Request.Form.Get("page"))
return params, nil
}
func (r *recentRunsParams) statusString() string {
if r.status == run.Status_STATUS_UNSPECIFIED {
return ""
}
return r.status.String()
}
func (r *recentRunsParams) modeString() string {
return string(r.mode)
}
func searchRuns(ctx context.Context, project string, params recentRunsParams) (runs []*run.Run, prev, next string, err error) {
var pageToken *run.PageToken
if params.pageTokenString != "" {
pageToken = &run.PageToken{}
if err = pagination.DecryptPageToken(ctx, params.pageTokenString, pageToken); err != nil {
// Log but don't return to the user entire error to avoid any accidental
// leakage.
logging.Warningf(ctx, "bad page token: %s", err)
err = fmt.Errorf("bad page token")
return
}
}
var qb interface {
LoadRuns(context.Context, ...run.LoadRunChecker) ([]*run.Run, *run.PageToken, error)
}
if project == "" {
qb = run.RecentQueryBuilder{
Limit: 50,
CheckProjectAccess: acls.CheckProjectAccess,
Status: params.status,
}.PageToken(pageToken)
} else {
switch ok, err := acls.CheckProjectAccess(ctx, project); {
case err != nil:
return nil, "", "", err
case !ok:
// Return NotFound error in the case of access denied.
//
// Rationale: the caller shouldn't be able to distinguish between
// project not existing and not having access to the project, because
// it may leak the existence of the project.
return nil, "", "", appstatus.Errorf(codes.NotFound, "Project %q not found", project)
}
qb = run.ProjectQueryBuilder{
Project: project,
Limit: 50,
Status: params.status,
}.PageToken(pageToken)
}
var nextPageToken *run.PageToken
runs, nextPageToken, err = qb.LoadRuns(ctx, acls.NewRunReadChecker())
if err != nil {
return
}
logging.Debugf(ctx, "%d runs retrieved", len(runs))
next, err = pagination.EncryptPageToken(ctx, nextPageToken)
if err != nil {
return
}
prev, err = pageTokens(ctx, params.pageTokenString, next)
return
}
func resolveRunsCLs(ctx context.Context, runs []*run.Run) ([]runWithExternalCLs, error) {
cls := make(map[common.CLID]*changelist.CL, len(runs))
for _, r := range runs {
for _, clid := range r.CLs {
if cls[clid] == nil {
cls[clid] = &changelist.CL{ID: clid}
}
}
}
if _, err := changelist.LoadCLsMap(ctx, cls); err != nil {
return nil, err
}
out := make([]runWithExternalCLs, len(runs))
for i, r := range runs {
out[i].Run = r
out[i].ExternalCLs = make([]changelist.ExternalID, len(r.CLs))
for j, clid := range r.CLs {
out[i].ExternalCLs[j] = cls[clid].ExternalID
}
}
return out, nil
}
type runWithExternalCLs struct {
*run.Run
ExternalCLs []changelist.ExternalID
}
var tokenCache = layered.Cache{
ProcessLRUCache: caching.RegisterLRUCache(1024),
GlobalNamespace: "recent_cv_runs_page_token_cache",
Marshal: func(item interface{}) ([]byte, error) {
return []byte(item.(string)), nil
},
Unmarshal: func(blob []byte) (interface{}, error) {
return string(blob), nil
},
}
var tokenExp = 24 * time.Hour
// pageTokens caches the current pageToken associated to the next,
// so as to populate the previous page link when rendering the next page.
// Also returns a previously saved page token pointing to the previous page.
func pageTokens(ctx context.Context, pageToken, nextPageToken string) (prev string, err error) {
// A whitespace will cause a 'Previous' link to render with no page token.
// i.e. going to the first page.
// An empty string will not render any 'Previous' link.
// TODO(crbug.com/1249253): Consider redirecting to the first page of the
// query when the given page token is valid, but we can't retrieve its
// previous page.
blankToken := " "
var cachedV interface{}
if pageToken == "" {
pageToken = blankToken
} else {
cachedV, err = tokenCache.GetOrCreate(ctx, pageToken, func() (v interface{}, exp time.Duration, err error) {
// We haven't seen this token yet, we don't know what its previous page is.
return "", 0, nil
})
if err != nil {
return
}
prev = cachedV.(string)
}
_, err = tokenCache.GetOrCreate(ctx, nextPageToken, func() (v interface{}, exp time.Duration, err error) {
return pageToken, tokenExp, nil
})
return
}