go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/frontend/view_console.go (about)

     1  // Copyright 2017 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package frontend
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/json"
    21  	"fmt"
    22  	"html/template"
    23  	"net/http"
    24  	"net/url"
    25  	"strings"
    26  	"time"
    27  
    28  	"google.golang.org/grpc/codes"
    29  
    30  	"go.chromium.org/luci/buildbucket/bbperms"
    31  	"go.chromium.org/luci/common/clock"
    32  	"go.chromium.org/luci/common/data/stringset"
    33  	"go.chromium.org/luci/common/errors"
    34  	"go.chromium.org/luci/common/logging"
    35  	"go.chromium.org/luci/common/sync/parallel"
    36  	"go.chromium.org/luci/grpc/grpcutil"
    37  	"go.chromium.org/luci/server/auth"
    38  	"go.chromium.org/luci/server/caching/layered"
    39  	"go.chromium.org/luci/server/router"
    40  	"go.chromium.org/luci/server/templates"
    41  
    42  	"go.chromium.org/luci/common/api/gitiles"
    43  	"go.chromium.org/luci/milo/frontend/ui"
    44  	"go.chromium.org/luci/milo/internal/buildsource"
    45  	"go.chromium.org/luci/milo/internal/git"
    46  	"go.chromium.org/luci/milo/internal/model"
    47  	"go.chromium.org/luci/milo/internal/projectconfig"
    48  	"go.chromium.org/luci/milo/internal/utils"
    49  	projectconfigpb "go.chromium.org/luci/milo/proto/projectconfig"
    50  )
    51  
    52  func logTimer(c context.Context, message string) func() {
    53  	tStart := clock.Now(c)
    54  	return func() {
    55  		logging.Debugf(c, "%s: took %s", message, clock.Since(c, tStart))
    56  	}
    57  }
    58  
    59  // validateFaviconURL checks to see if the URL is well-formed and from an allowed host.
    60  func validateFaviconURL(faviconURL string) error {
    61  	parsedFaviconURL, err := url.Parse(faviconURL)
    62  	if err != nil {
    63  		return err
    64  	}
    65  	host := strings.SplitN(parsedFaviconURL.Host, ":", 2)[0]
    66  	if host != "storage.googleapis.com" {
    67  		return fmt.Errorf("%q is not a valid FaviconURL hostname", host)
    68  	}
    69  	return nil
    70  }
    71  
    72  func getFaviconURL(c context.Context, def *projectconfigpb.Console) string {
    73  	if def.FaviconUrl == "" {
    74  		return ""
    75  	}
    76  	if err := validateFaviconURL(def.FaviconUrl); err != nil {
    77  		logging.WithError(err).Warningf(c, "invalid favicon URL")
    78  		return ""
    79  	}
    80  	return def.FaviconUrl
    81  }
    82  
    83  // columnSummaryFn is called by buildTreeFromDef.
    84  //
    85  // columnIdx is the index of the current column we're processing. This
    86  // corresponds to one of the Builder messages in the Console config proto
    87  // message.
    88  //
    89  // This function should return a BuilderSummary and []BuildSummary for the
    90  // specified console column.
    91  type columnSummaryFn func(columnIdx int) (*model.BuilderSummary, []*model.BuildSummary)
    92  
    93  func buildTreeFromDef(def *projectconfigpb.Console, getColumnSummaries columnSummaryFn) (*ui.Category, int) {
    94  	// Build console table tree from builders.
    95  	categoryTree := ui.NewCategory("")
    96  	depth := 0
    97  	for columnIdx, builderMsg := range def.Builders {
    98  		ref := &ui.BuilderRef{
    99  			ShortName: builderMsg.ShortName,
   100  		}
   101  		ref.Builder, ref.Build = getColumnSummaries(columnIdx)
   102  		if ref.Builder != nil {
   103  			// TODO(iannucci): This is redundant; use Builder.BuilderID directly.
   104  			ref.ID = ref.Builder.BuilderID
   105  		}
   106  		categories := builderMsg.ParseCategory()
   107  		if len(categories) > depth {
   108  			depth = len(categories)
   109  		}
   110  		categoryTree.AddBuilder(categories, ref)
   111  	}
   112  	return categoryTree, depth
   113  }
   114  
   115  // getConsoleGroups extracts the console summaries for all header summaries
   116  // out of the summaries map into console groups for the header.
   117  func getConsoleGroups(def *projectconfigpb.Header, summaries map[projectconfig.ConsoleID]*ui.BuilderSummaryGroup) []ui.ConsoleGroup {
   118  	if def == nil || len(def.GetConsoleGroups()) == 0 {
   119  		// No header, no console groups.
   120  		return nil
   121  	}
   122  	groups := def.GetConsoleGroups()
   123  	consoleGroups := make([]ui.ConsoleGroup, len(groups))
   124  	for i, group := range groups {
   125  		groupSummaries := make([]*ui.BuilderSummaryGroup, len(group.ConsoleIds))
   126  		for j, id := range group.ConsoleIds {
   127  			cid, err := projectconfig.ParseConsoleID(id)
   128  			if err != nil {
   129  				// This should never happen, the consoleID was already validated further
   130  				// upstream.
   131  				panic(err)
   132  			}
   133  			if consoleSummary, ok := summaries[cid]; ok {
   134  				groupSummaries[j] = consoleSummary
   135  			} else {
   136  				// This should never happen.
   137  				panic(fmt.Sprintf("could not find console summary %s", id))
   138  			}
   139  		}
   140  		consoleGroups[i].Consoles = groupSummaries
   141  		if group.Title != nil {
   142  			ariaLabel := "console group " + group.Title.Text
   143  			consoleGroups[i].Title = ui.NewLink(group.Title.Text, group.Title.Url, ariaLabel)
   144  			consoleGroups[i].Title.Alt = group.Title.Alt
   145  		}
   146  	}
   147  	return consoleGroups
   148  }
   149  
   150  func consoleRowCommits(c context.Context, project string, def *projectconfigpb.Console, limit int) (
   151  	[]*buildsource.ConsoleRow, []ui.Commit, error) {
   152  
   153  	tGitiles := logTimer(c, "Rows: loading commit from gitiles")
   154  	repoHost, repoProject, err := gitiles.ParseRepoURL(def.RepoUrl)
   155  	if err != nil {
   156  		return nil, nil, errors.Annotate(err, "invalid repo URL %q in the config", def.RepoUrl).Err()
   157  	}
   158  	rawCommits, err := git.Get(c).CombinedLogs(c, repoHost, repoProject, def.ExcludeRef, def.Refs, limit)
   159  	switch grpcutil.Code(err) {
   160  	case codes.OK:
   161  		// Do nothing, all is good.
   162  	case codes.NotFound:
   163  		return nil, nil, errors.Reason("incorrect repo URL %q in the config or no access", def.RepoUrl).
   164  			Tag(grpcutil.NotFoundTag).Err()
   165  	default:
   166  		return nil, nil, err
   167  	}
   168  	tGitiles()
   169  
   170  	commitIDs := make([]string, len(rawCommits))
   171  	for i, c := range rawCommits {
   172  		commitIDs[i] = c.Id
   173  	}
   174  
   175  	tBuilds := logTimer(c, "Rows: loading builds")
   176  	rows, err := buildsource.GetConsoleRows(c, project, def, commitIDs)
   177  	tBuilds()
   178  	if err != nil {
   179  		return nil, nil, err
   180  	}
   181  
   182  	// Build list of commits.
   183  	commits := make([]ui.Commit, len(rawCommits))
   184  	for row, commit := range rawCommits {
   185  		ct := commit.Committer.Time.AsTime()
   186  		commits[row] = ui.Commit{
   187  			AuthorName:  commit.Author.Name,
   188  			AuthorEmail: commit.Author.Email,
   189  			CommitTime:  ct,
   190  			Repo:        def.RepoUrl,
   191  			Description: commit.Message,
   192  			Revision: ui.NewLink(
   193  				commit.Id,
   194  				def.RepoUrl+"/+/"+commit.Id,
   195  				fmt.Sprintf("commit by %s", commit.Author.Email)),
   196  		}
   197  	}
   198  
   199  	return rows, commits, nil
   200  }
   201  
   202  func console(c context.Context, project, id string, limit int, con *projectconfig.Console, headerCons []*projectconfig.Console, consoleGroupsErr error) (*ui.Console, error) {
   203  	def := &con.Def
   204  	consoleID := projectconfig.ConsoleID{Project: project, ID: id}
   205  	var header *ui.ConsoleHeader
   206  	var rows []*buildsource.ConsoleRow
   207  	var commits []ui.Commit
   208  	var builderSummaries map[projectconfig.ConsoleID]*ui.BuilderSummaryGroup
   209  	// Get 3 things in parallel:
   210  	// 1. The console header (except for summaries)
   211  	// 2. The console header summaries + this console's builders summaries.
   212  	// 3. The console body (rows + commits)
   213  	if err := parallel.FanOutIn(func(ch chan<- func() error) {
   214  		if def.Header != nil {
   215  			ch <- func() (err error) {
   216  				defer logTimer(c, "header")()
   217  				header, err = consoleHeader(c, project, def.Header)
   218  				return
   219  			}
   220  		}
   221  		ch <- func() (err error) {
   222  			defer logTimer(c, "summaries")()
   223  			builderSummaries, err = buildsource.GetConsoleSummariesFromDefs(c, append(headerCons, con), project)
   224  			return
   225  		}
   226  		ch <- func() (err error) {
   227  			defer logTimer(c, "rows")()
   228  			rows, commits, err = consoleRowCommits(c, project, def, limit)
   229  			return
   230  		}
   231  	}); err != nil {
   232  		return nil, err
   233  	}
   234  
   235  	// Reassemble builder summaries into both the current console
   236  	// and also the header.
   237  	if header != nil {
   238  		if consoleGroupsErr == nil {
   239  			header.ConsoleGroups = getConsoleGroups(def.Header, builderSummaries)
   240  		} else {
   241  			header.ConsoleGroupsErr = consoleGroupsErr
   242  		}
   243  	}
   244  
   245  	// Reassemble the builder summaries and rows into the categoryTree.
   246  	categoryTree, depth := buildTreeFromDef(def, func(columnIdx int) (*model.BuilderSummary, []*model.BuildSummary) {
   247  		builds := make([]*model.BuildSummary, len(commits))
   248  		for row := range commits {
   249  			if summaries := rows[row].Builds[columnIdx]; len(summaries) > 0 {
   250  				builds[row] = summaries[0]
   251  			}
   252  		}
   253  		return builderSummaries[consoleID].Builders[columnIdx], builds
   254  	})
   255  
   256  	return &ui.Console{
   257  		Name:       def.Name,
   258  		Project:    project,
   259  		Header:     header,
   260  		Commit:     commits,
   261  		Table:      *categoryTree,
   262  		MaxDepth:   depth + 1,
   263  		FaviconURL: getFaviconURL(c, def),
   264  	}, nil
   265  }
   266  
   267  var treeStatusCache = layered.RegisterCache(layered.Parameters[*ui.TreeStatus]{
   268  	ProcessCacheCapacity: 256,
   269  	GlobalNamespace:      "tree-status",
   270  	Marshal: func(item *ui.TreeStatus) ([]byte, error) {
   271  		return json.Marshal(item)
   272  	},
   273  	Unmarshal: func(blob []byte) (*ui.TreeStatus, error) {
   274  		treeStatus := &ui.TreeStatus{}
   275  		err := json.Unmarshal(blob, treeStatus)
   276  		return treeStatus, err
   277  	},
   278  })
   279  
   280  // getTreeStatus returns the current tree status from the chromium-status app.
   281  // This never errors, instead it constructs a fake purple TreeStatus
   282  func getTreeStatus(c context.Context, host string) *ui.TreeStatus {
   283  	q := url.Values{}
   284  	q.Add("format", "json")
   285  	url := (&url.URL{
   286  		Scheme:   "https",
   287  		Host:     host,
   288  		Path:     "current",
   289  		RawQuery: q.Encode(),
   290  	}).String()
   291  	status, err := treeStatusCache.GetOrCreate(c, url, func() (v *ui.TreeStatus, exp time.Duration, err error) {
   292  		out := &ui.TreeStatus{}
   293  		if err := utils.GetJSONData(http.DefaultClient, url, out); err != nil {
   294  			return nil, 0, err
   295  		}
   296  		return out, 30 * time.Second, nil
   297  	})
   298  
   299  	if err != nil {
   300  		// Generate a fake tree status.
   301  		logging.WithError(err).Errorf(c, "loading tree status")
   302  		status = &ui.TreeStatus{
   303  			GeneralState: "maintenance",
   304  			Message:      "could not load tree status",
   305  		}
   306  	}
   307  
   308  	return status
   309  }
   310  
   311  var oncallDataCache = layered.RegisterCache(layered.Parameters[*ui.Oncall]{
   312  	ProcessCacheCapacity: 256,
   313  	GlobalNamespace:      "oncall-data",
   314  	Marshal: func(item *ui.Oncall) ([]byte, error) {
   315  		return json.Marshal(item)
   316  	},
   317  	Unmarshal: func(blob []byte) (*ui.Oncall, error) {
   318  		oncall := &ui.Oncall{}
   319  		err := json.Unmarshal(blob, oncall)
   320  		return oncall, err
   321  	},
   322  })
   323  
   324  // getOncallData fetches oncall data and caches it for 10 minutes.
   325  func getOncallData(c context.Context, config *projectconfigpb.Oncall) (*ui.OncallSummary, error) {
   326  	oncall, err := oncallDataCache.GetOrCreate(c, config.Url, func() (v *ui.Oncall, exp time.Duration, err error) {
   327  		out := &ui.Oncall{}
   328  		if err := utils.GetJSONData(http.DefaultClient, config.Url, out); err != nil {
   329  			return nil, 0, err
   330  		}
   331  		return out, 10 * time.Minute, nil
   332  	})
   333  
   334  	var renderedHTML template.HTML
   335  	if err == nil {
   336  		renderedHTML = renderOncallers(config, oncall)
   337  	} else {
   338  		renderedHTML = template.HTML("ERROR: Fetching oncall failed")
   339  	}
   340  	return &ui.OncallSummary{
   341  		Name:      config.Name,
   342  		Oncallers: renderedHTML,
   343  	}, nil
   344  }
   345  
   346  // renderOncallers renders a summary string to be displayed in the UI, showing
   347  // the current oncallers.
   348  func renderOncallers(config *projectconfigpb.Oncall, jsonResult *ui.Oncall) template.HTML {
   349  	var oncallers string
   350  	if len(jsonResult.Emails) == 1 {
   351  		oncallers = jsonResult.Emails[0]
   352  	} else if len(jsonResult.Emails) > 1 {
   353  		if config.ShowPrimarySecondaryLabels {
   354  			var sb strings.Builder
   355  			fmt.Fprintf(&sb, "%v (primary)", jsonResult.Emails[0])
   356  			for _, oncaller := range jsonResult.Emails[1:] {
   357  				fmt.Fprintf(&sb, ", %v (secondary)", oncaller)
   358  			}
   359  			oncallers = sb.String()
   360  		} else {
   361  			oncallers = strings.Join(jsonResult.Emails, ", ")
   362  		}
   363  	} else if jsonResult.Primary != "" {
   364  		if len(jsonResult.Secondaries) > 0 {
   365  			var sb strings.Builder
   366  			fmt.Fprintf(&sb, "%v (primary)", jsonResult.Primary)
   367  			for _, oncaller := range jsonResult.Secondaries {
   368  				fmt.Fprintf(&sb, ", %v (secondary)", oncaller)
   369  			}
   370  			oncallers = sb.String()
   371  		} else {
   372  			oncallers = jsonResult.Primary
   373  		}
   374  	} else {
   375  		oncallers = "<none>"
   376  	}
   377  	return utils.ObfuscateEmail(utils.ShortenEmail(oncallers))
   378  }
   379  
   380  func consoleHeaderOncall(c context.Context, config []*projectconfigpb.Oncall) ([]*ui.OncallSummary, error) {
   381  	// Get oncall data from URLs.
   382  	oncalls := make([]*ui.OncallSummary, len(config))
   383  	err := parallel.WorkPool(8, func(ch chan<- func() error) {
   384  		for i, oc := range config {
   385  			i := i
   386  			oc := oc
   387  			ch <- func() (err error) {
   388  				oncalls[i], err = getOncallData(c, oc)
   389  				return
   390  			}
   391  		}
   392  	})
   393  	return oncalls, err
   394  }
   395  
   396  func consoleHeader(c context.Context, project string, header *projectconfigpb.Header) (*ui.ConsoleHeader, error) {
   397  	// Return nil if the header is empty.
   398  	switch {
   399  	case len(header.Oncalls) != 0:
   400  		// continue
   401  	case len(header.Links) != 0:
   402  		// continue
   403  	case header.TreeStatusHost != "":
   404  		// continue
   405  	default:
   406  		return nil, nil
   407  	}
   408  
   409  	var oncalls []*ui.OncallSummary
   410  	var treeStatus *ui.TreeStatus
   411  	// Get the oncall and tree status concurrently.
   412  	if err := parallel.FanOutIn(func(ch chan<- func() error) {
   413  		ch <- func() (err error) {
   414  			// Hide the oncall section to external users.
   415  			// TODO(weiweilin): Once the upstream service (rotation proxy) supports
   416  			// ACL checks, we should use the user's credential to query the oncall
   417  			// data and display it.
   418  			user := auth.CurrentUser(c)
   419  			if !strings.HasSuffix(user.Email, "@google.com") {
   420  				return nil
   421  			}
   422  
   423  			oncalls, err = consoleHeaderOncall(c, header.Oncalls)
   424  			if err != nil {
   425  				logging.WithError(err).Errorf(c, "getting oncalls")
   426  			}
   427  			return nil
   428  		}
   429  		if header.TreeStatusHost != "" {
   430  			ch <- func() error {
   431  				treeStatus = getTreeStatus(c, header.TreeStatusHost)
   432  				treeStatus.URL = &url.URL{Scheme: "https", Host: header.TreeStatusHost}
   433  				return nil
   434  			}
   435  		}
   436  	}); err != nil {
   437  		return nil, err
   438  	}
   439  
   440  	// Restructure links as resp data structures.
   441  	//
   442  	// This should be a one-to-one transformation.
   443  	links := make([]ui.LinkGroup, len(header.Links))
   444  	for i, linkGroup := range header.Links {
   445  		mlinks := make([]*ui.Link, len(linkGroup.Links))
   446  		for j, link := range linkGroup.Links {
   447  			ariaLabel := fmt.Sprintf("%s in %s", link.Text, linkGroup.Name)
   448  			mlinks[j] = ui.NewLink(link.Text, link.Url, ariaLabel)
   449  		}
   450  		links[i] = ui.LinkGroup{
   451  			Name:  ui.NewLink(linkGroup.Name, "", ""),
   452  			Links: mlinks,
   453  		}
   454  	}
   455  
   456  	return &ui.ConsoleHeader{
   457  		Oncalls:    oncalls,
   458  		Links:      links,
   459  		TreeStatus: treeStatus,
   460  	}, nil
   461  }
   462  
   463  // consoleRenderer is a wrapper around Console to provide additional methods.
   464  type consoleRenderer struct {
   465  	*ui.Console
   466  }
   467  
   468  // ConsoleTable generates the main console table html.
   469  //
   470  // This cannot be generated with templates due to the 'recursive' nature of
   471  // this layout.
   472  func (c consoleRenderer) ConsoleTable() template.HTML {
   473  	var buffer bytes.Buffer
   474  	// The first node is a dummy node
   475  	for _, column := range c.Table.Children() {
   476  		column.RenderHTML(&buffer, 1, c.MaxDepth)
   477  	}
   478  	return template.HTML(buffer.String())
   479  }
   480  
   481  // ConsoleSummary generates the html for the console's builders in the console list.
   482  //
   483  // It is similar to ConsoleTable, but flattens the structure.
   484  func (c consoleRenderer) ConsoleSummary() template.HTML {
   485  	var buffer bytes.Buffer
   486  	for _, column := range c.Table.Children() {
   487  		column.RenderHTML(&buffer, 1, -1)
   488  	}
   489  	return template.HTML(buffer.String())
   490  }
   491  
   492  func (c consoleRenderer) BuilderLink(bs *model.BuildSummary) (*ui.Link, error) {
   493  	_, _, builderName, err := buildsource.BuilderID(bs.BuilderID).Split()
   494  	if err != nil {
   495  		return nil, err
   496  	}
   497  	return ui.NewLink(builderName, "/"+bs.BuilderID, fmt.Sprintf("builder %s", builderName)), nil
   498  }
   499  
   500  // consoleHeaderGroupIDs extracts the console group IDs out of the header config.
   501  func consoleHeaderGroupIDs(project string, config []*projectconfigpb.ConsoleSummaryGroup) ([]projectconfig.ConsoleID, error) {
   502  	consoleIDSet := map[projectconfig.ConsoleID]struct{}{}
   503  	for _, group := range config {
   504  		for _, id := range group.ConsoleIds {
   505  			cid, err := projectconfig.ParseConsoleID(id)
   506  			if err != nil {
   507  				return nil, err
   508  			}
   509  			consoleIDSet[cid] = struct{}{}
   510  		}
   511  	}
   512  	consoleIDs := make([]projectconfig.ConsoleID, 0, len(consoleIDSet))
   513  	for cid := range consoleIDSet {
   514  		consoleIDs = append(consoleIDs, cid)
   515  	}
   516  	return consoleIDs, nil
   517  }
   518  
   519  // filterUnauthorizedBuildersFromConsoles filters out builders the user does not have access to.
   520  func filterUnauthorizedBuildersFromConsoles(c context.Context, cons []*projectconfig.Console) error {
   521  	allRealms := stringset.New(0)
   522  	for _, con := range cons {
   523  		allRealms = allRealms.Union(con.BuilderRealms())
   524  	}
   525  
   526  	allowedRealms := stringset.New(0)
   527  	for realm := range allRealms {
   528  		allowed, err := auth.HasPermission(c, bbperms.BuildsList, realm, nil)
   529  		if err != nil {
   530  			return err
   531  		}
   532  		if allowed {
   533  			allowedRealms.Add(realm)
   534  		}
   535  	}
   536  
   537  	for _, con := range cons {
   538  		con.FilterBuilders(allowedRealms)
   539  	}
   540  	return nil
   541  }
   542  
   543  // ConsoleHandler renders the console page.
   544  func ConsoleHandler(c *router.Context) error {
   545  	project := c.Params.ByName("project")
   546  	if project == "" {
   547  		return errors.New("missing project", grpcutil.InvalidArgumentTag)
   548  	}
   549  	group := c.Params.ByName("group")
   550  
   551  	// Get console from datastore and filter out builders from the definition.
   552  	con, err := projectconfig.GetConsole(c.Request.Context(), project, group)
   553  	switch {
   554  	case err != nil:
   555  		return err
   556  	case con.IsExternal():
   557  		// We don't allow navigating directly to external consoles.
   558  		return projectconfig.ErrConsoleNotFound
   559  	case con.Def.BuilderViewOnly:
   560  		redirect("/p/:project/g/:group/builders", http.StatusFound)(c)
   561  		return nil
   562  	}
   563  
   564  	defaultLimit := 50
   565  	if con.Def.DefaultCommitLimit > 0 {
   566  		defaultLimit = int(con.Def.DefaultCommitLimit)
   567  	}
   568  	const maxLimit = 1000
   569  	limit := defaultLimit
   570  	if tLimit := GetLimit(c.Request, -1); tLimit >= 0 {
   571  		limit = tLimit
   572  	}
   573  	if limit > maxLimit {
   574  		limit = maxLimit
   575  	}
   576  
   577  	var headerCons []*projectconfig.Console
   578  	var headerConsError error
   579  	if con.Def.Header != nil {
   580  		ids, err := consoleHeaderGroupIDs(project, con.Def.Header.GetConsoleGroups())
   581  		if err != nil {
   582  			return err
   583  		}
   584  		headerCons, err = projectconfig.GetConsoles(c.Request.Context(), ids)
   585  		if err != nil {
   586  			headerConsError = errors.Annotate(err, "error getting header consoles").Err()
   587  			headerCons = make([]*projectconfig.Console, 0)
   588  		}
   589  	}
   590  	if err := filterUnauthorizedBuildersFromConsoles(c.Request.Context(), append(headerCons, con)); err != nil {
   591  		return errors.Annotate(err, "error authorizing user").Err()
   592  	}
   593  
   594  	// Process the request and generate a renderable structure.
   595  	result, err := console(c.Request.Context(), project, group, limit, con, headerCons, headerConsError)
   596  	if err != nil {
   597  		return err
   598  	}
   599  
   600  	templates.MustRender(c.Request.Context(), c.Writer, "pages/console.html", templates.Args{
   601  		"Console": consoleRenderer{result},
   602  		"Expand":  con.Def.DefaultExpand,
   603  	})
   604  	return nil
   605  }