go.tools/dashboard: add gccgo build dashboard.
This change adds a new build dashboard url to the existing appengine app: $dashurl/gccgo/ which will show the build status of gccgo. * Added Dashboard struct with exported Name, Rel(ative)Path, and Packages fields. * Added Dashboard Context method that returns an appengine context with a namespace corresponding to the dashboard's name. * Modified HandlerFuncs to use Dashboard's Context method for all appengine requests. * Modified ui template to show different title/header for separate dashboard and added dashboard tab. R=adg CC=golang-dev https://golang.org/cl/13753043
This commit is contained in:
parent
0e6d095d11
commit
7bcc81e644
|
@ -13,8 +13,8 @@ handlers:
|
||||||
static_dir: static
|
static_dir: static
|
||||||
- url: /log/.+
|
- url: /log/.+
|
||||||
script: _go_app
|
script: _go_app
|
||||||
- url: /(|commit|packages|result|tag|todo)
|
- url: /(|gccgo/)(|commit|packages|result|tag|todo)
|
||||||
script: _go_app
|
script: _go_app
|
||||||
- url: /(init|buildtest|key|_ah/queue/go/delay)
|
- url: /(|gccgo/)(init|buildtest|key|_ah/queue/go/delay)
|
||||||
script: _go_app
|
script: _go_app
|
||||||
login: admin
|
login: admin
|
|
@ -84,7 +84,7 @@ func GetPackage(c appengine.Context, path string) (*Package, error) {
|
||||||
// In other words, all Commits with the same PackagePath belong to the same
|
// In other words, all Commits with the same PackagePath belong to the same
|
||||||
// datastore entity group.
|
// datastore entity group.
|
||||||
type Commit struct {
|
type Commit struct {
|
||||||
PackagePath string // (empty for Go commits)
|
PackagePath string // (empty for main repo commits)
|
||||||
Hash string
|
Hash string
|
||||||
ParentHash string
|
ParentHash string
|
||||||
Num int // Internal monotonic counter unique to this package.
|
Num int // Internal monotonic counter unique to this package.
|
||||||
|
|
|
@ -0,0 +1,113 @@
|
||||||
|
// Copyright 2013 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// +build appengine
|
||||||
|
|
||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"appengine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dashboard describes a unique build dashboard.
|
||||||
|
type Dashboard struct {
|
||||||
|
Name string // This dashboard's name and namespace
|
||||||
|
RelPath string // The relative url path
|
||||||
|
Packages []*Package // The project's packages to build
|
||||||
|
}
|
||||||
|
|
||||||
|
// dashboardForRequest returns the appropriate dashboard for a given URL path.
|
||||||
|
func dashboardForRequest(r *http.Request) *Dashboard {
|
||||||
|
if strings.HasPrefix(r.URL.Path, gccgoDash.RelPath) {
|
||||||
|
return gccgoDash
|
||||||
|
}
|
||||||
|
return goDash
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context returns a namespaced context for this dashboard, or panics if it
|
||||||
|
// fails to create a new context.
|
||||||
|
func (d *Dashboard) Context(c appengine.Context) appengine.Context {
|
||||||
|
// No namespace needed for the original Go dashboard.
|
||||||
|
if d.Name == "Go" {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
n, err := appengine.Namespace(c, d.Name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// the currently known dashboards.
|
||||||
|
var dashboards = []*Dashboard{goDash, gccgoDash}
|
||||||
|
|
||||||
|
// goDash is the dashboard for the main go repository.
|
||||||
|
var goDash = &Dashboard{
|
||||||
|
Name: "Go",
|
||||||
|
RelPath: "/",
|
||||||
|
Packages: goPackages,
|
||||||
|
}
|
||||||
|
|
||||||
|
// goPackages is a list of all of the packages built by the main go repository.
|
||||||
|
var goPackages = []*Package{
|
||||||
|
{
|
||||||
|
Kind: "go",
|
||||||
|
Name: "Go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: "subrepo",
|
||||||
|
Name: "go.blog",
|
||||||
|
Path: "code.google.com/p/go.blog",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: "subrepo",
|
||||||
|
Name: "go.codereview",
|
||||||
|
Path: "code.google.com/p/go.codereview",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: "subrepo",
|
||||||
|
Name: "go.crypto",
|
||||||
|
Path: "code.google.com/p/go.crypto",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: "subrepo",
|
||||||
|
Name: "go.exp",
|
||||||
|
Path: "code.google.com/p/go.exp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: "subrepo",
|
||||||
|
Name: "go.image",
|
||||||
|
Path: "code.google.com/p/go.image",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: "subrepo",
|
||||||
|
Name: "go.net",
|
||||||
|
Path: "code.google.com/p/go.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: "subrepo",
|
||||||
|
Name: "go.talks",
|
||||||
|
Path: "code.google.com/p/go.talks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: "subrepo",
|
||||||
|
Name: "go.tools",
|
||||||
|
Path: "code.google.com/p/go.tools",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// gccgoDash is the dashboard for gccgo.
|
||||||
|
var gccgoDash = &Dashboard{
|
||||||
|
Name: "Gccgo",
|
||||||
|
RelPath: "/gccgo/",
|
||||||
|
Packages: []*Package{
|
||||||
|
{
|
||||||
|
Kind: "gccgo",
|
||||||
|
Name: "Gccgo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
|
@ -33,7 +33,7 @@ const commitsPerPage = 30
|
||||||
//
|
//
|
||||||
// This handler is used by a gobuilder process in -commit mode.
|
// This handler is used by a gobuilder process in -commit mode.
|
||||||
func commitHandler(r *http.Request) (interface{}, error) {
|
func commitHandler(r *http.Request) (interface{}, error) {
|
||||||
c := appengine.NewContext(r)
|
c := contextForRequest(r)
|
||||||
com := new(Commit)
|
com := new(Commit)
|
||||||
|
|
||||||
if r.Method == "GET" {
|
if r.Method == "GET" {
|
||||||
|
@ -132,7 +132,7 @@ func tagHandler(r *http.Request) (interface{}, error) {
|
||||||
if err := t.Valid(); err != nil {
|
if err := t.Valid(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c := appengine.NewContext(r)
|
c := contextForRequest(r)
|
||||||
defer cache.Tick(c)
|
defer cache.Tick(c)
|
||||||
_, err := datastore.Put(c, t.Key(c), t)
|
_, err := datastore.Put(c, t.Key(c), t)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -148,7 +148,7 @@ type Todo struct {
|
||||||
// It expects "builder" and "kind" query parameters and returns a *Todo value.
|
// It expects "builder" and "kind" query parameters and returns a *Todo value.
|
||||||
// Multiple "kind" parameters may be specified.
|
// Multiple "kind" parameters may be specified.
|
||||||
func todoHandler(r *http.Request) (interface{}, error) {
|
func todoHandler(r *http.Request) (interface{}, error) {
|
||||||
c := appengine.NewContext(r)
|
c := contextForRequest(r)
|
||||||
now := cache.Now(c)
|
now := cache.Now(c)
|
||||||
key := "build-todo-" + r.Form.Encode()
|
key := "build-todo-" + r.Form.Encode()
|
||||||
var todo *Todo
|
var todo *Todo
|
||||||
|
@ -257,7 +257,7 @@ func buildTodo(c appengine.Context, builder, packagePath, goHash string) (interf
|
||||||
// by the dashboard.
|
// by the dashboard.
|
||||||
func packagesHandler(r *http.Request) (interface{}, error) {
|
func packagesHandler(r *http.Request) (interface{}, error) {
|
||||||
kind := r.FormValue("kind")
|
kind := r.FormValue("kind")
|
||||||
c := appengine.NewContext(r)
|
c := contextForRequest(r)
|
||||||
now := cache.Now(c)
|
now := cache.Now(c)
|
||||||
key := "build-packages-" + kind
|
key := "build-packages-" + kind
|
||||||
var p []*Package
|
var p []*Package
|
||||||
|
@ -282,7 +282,7 @@ func resultHandler(r *http.Request) (interface{}, error) {
|
||||||
return nil, errBadMethod(r.Method)
|
return nil, errBadMethod(r.Method)
|
||||||
}
|
}
|
||||||
|
|
||||||
c := appengine.NewContext(r)
|
c := contextForRequest(r)
|
||||||
res := new(Result)
|
res := new(Result)
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
if err := json.NewDecoder(r.Body).Decode(res); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(res); err != nil {
|
||||||
|
@ -326,7 +326,7 @@ func resultHandler(r *http.Request) (interface{}, error) {
|
||||||
// It handles paths like "/log/hash".
|
// It handles paths like "/log/hash".
|
||||||
func logHandler(w http.ResponseWriter, r *http.Request) {
|
func logHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-type", "text/plain; charset=utf-8")
|
||||||
c := appengine.NewContext(r)
|
c := contextForRequest(r)
|
||||||
hash := r.URL.Path[len("/log/"):]
|
hash := r.URL.Path[len("/log/"):]
|
||||||
key := datastore.NewKey(c, "Log", hash, 0, nil)
|
key := datastore.NewKey(c, "Log", hash, 0, nil)
|
||||||
l := new(Log)
|
l := new(Log)
|
||||||
|
@ -361,7 +361,7 @@ func (e errBadMethod) Error() string {
|
||||||
// supplied key and builder query parameters.
|
// supplied key and builder query parameters.
|
||||||
func AuthHandler(h dashHandler) http.HandlerFunc {
|
func AuthHandler(h dashHandler) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
c := appengine.NewContext(r)
|
c := contextForRequest(r)
|
||||||
|
|
||||||
// Put the URL Query values into r.Form to avoid parsing the
|
// Put the URL Query values into r.Form to avoid parsing the
|
||||||
// request body when calling r.FormValue.
|
// request body when calling r.FormValue.
|
||||||
|
@ -401,24 +401,26 @@ func keyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
logErr(w, r, errors.New("must supply builder in query string"))
|
logErr(w, r, errors.New("must supply builder in query string"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c := appengine.NewContext(r)
|
c := contextForRequest(r)
|
||||||
fmt.Fprint(w, builderKey(c, builder))
|
fmt.Fprint(w, builderKey(c, builder))
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// admin handlers
|
for _, d := range dashboards {
|
||||||
http.HandleFunc("/init", initHandler)
|
// admin handlers
|
||||||
http.HandleFunc("/key", keyHandler)
|
http.HandleFunc(d.RelPath+"init", initHandler)
|
||||||
|
http.HandleFunc(d.RelPath+"key", keyHandler)
|
||||||
|
|
||||||
// authenticated handlers
|
// authenticated handlers
|
||||||
http.HandleFunc("/commit", AuthHandler(commitHandler))
|
http.HandleFunc(d.RelPath+"commit", AuthHandler(commitHandler))
|
||||||
http.HandleFunc("/packages", AuthHandler(packagesHandler))
|
http.HandleFunc(d.RelPath+"packages", AuthHandler(packagesHandler))
|
||||||
http.HandleFunc("/result", AuthHandler(resultHandler))
|
http.HandleFunc(d.RelPath+"result", AuthHandler(resultHandler))
|
||||||
http.HandleFunc("/tag", AuthHandler(tagHandler))
|
http.HandleFunc(d.RelPath+"tag", AuthHandler(tagHandler))
|
||||||
http.HandleFunc("/todo", AuthHandler(todoHandler))
|
http.HandleFunc(d.RelPath+"todo", AuthHandler(todoHandler))
|
||||||
|
|
||||||
// public handlers
|
// public handlers
|
||||||
http.HandleFunc("/log/", logHandler)
|
http.HandleFunc(d.RelPath+"log/", logHandler)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validHash(hash string) bool {
|
func validHash(hash string) bool {
|
||||||
|
@ -443,7 +445,11 @@ func builderKey(c appengine.Context, builder string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func logErr(w http.ResponseWriter, r *http.Request, err error) {
|
func logErr(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
appengine.NewContext(r).Errorf("Error: %v", err)
|
contextForRequest(r).Errorf("Error: %v", err)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
fmt.Fprint(w, "Error: ", err)
|
fmt.Fprint(w, "Error: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contextForRequest(r *http.Request) appengine.Context {
|
||||||
|
return dashboardForRequest(r).Context(appengine.NewContext(r))
|
||||||
|
}
|
||||||
|
|
|
@ -15,39 +15,11 @@ import (
|
||||||
"cache"
|
"cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultPackages specifies the Package records to be created by initHandler.
|
|
||||||
var defaultPackages = []*Package{
|
|
||||||
{Name: "Go", Kind: "go"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// subRepos specifies the Go project sub-repositories.
|
|
||||||
var subRepos = []string{
|
|
||||||
"blog",
|
|
||||||
"codereview",
|
|
||||||
"crypto",
|
|
||||||
"exp",
|
|
||||||
"image",
|
|
||||||
"net",
|
|
||||||
"talks",
|
|
||||||
"tools",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put subRepos into defaultPackages.
|
|
||||||
func init() {
|
|
||||||
for _, name := range subRepos {
|
|
||||||
p := &Package{
|
|
||||||
Kind: "subrepo",
|
|
||||||
Name: "go." + name,
|
|
||||||
Path: "code.google.com/p/go." + name,
|
|
||||||
}
|
|
||||||
defaultPackages = append(defaultPackages, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func initHandler(w http.ResponseWriter, r *http.Request) {
|
func initHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
c := appengine.NewContext(r)
|
d := dashboardForRequest(r)
|
||||||
|
c := d.Context(appengine.NewContext(r))
|
||||||
defer cache.Tick(c)
|
defer cache.Tick(c)
|
||||||
for _, p := range defaultPackages {
|
for _, p := range d.Packages {
|
||||||
err := datastore.Get(c, p.Key(c), new(Package))
|
err := datastore.Get(c, p.Key(c), new(Package))
|
||||||
if _, ok := err.(*datastore.ErrFieldMismatch); ok {
|
if _, ok := err.(*datastore.ErrFieldMismatch); ok {
|
||||||
// Some fields have been removed, so it's okay to ignore this error.
|
// Some fields have been removed, so it's okay to ignore this error.
|
||||||
|
|
|
@ -25,12 +25,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
http.HandleFunc("/", uiHandler)
|
for _, d := range dashboards {
|
||||||
|
http.HandleFunc(d.RelPath, uiHandler)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// uiHandler draws the build status page.
|
// uiHandler draws the build status page.
|
||||||
func uiHandler(w http.ResponseWriter, r *http.Request) {
|
func uiHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
c := appengine.NewContext(r)
|
d := dashboardForRequest(r)
|
||||||
|
c := d.Context(appengine.NewContext(r))
|
||||||
now := cache.Now(c)
|
now := cache.Now(c)
|
||||||
const key = "build-ui"
|
const key = "build-ui"
|
||||||
|
|
||||||
|
@ -48,7 +51,7 @@ func uiHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
commits, err := goCommits(c, page)
|
commits, err := dashCommits(c, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logErr(w, r, err)
|
logErr(w, r, err)
|
||||||
return
|
return
|
||||||
|
@ -73,7 +76,7 @@ func uiHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
p.Prev = page - 1
|
p.Prev = page - 1
|
||||||
p.HasPrev = true
|
p.HasPrev = true
|
||||||
}
|
}
|
||||||
data := &uiTemplateData{commits, builders, tipState, p}
|
data := &uiTemplateData{d, commits, builders, tipState, p}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := uiTemplate.Execute(&buf, data); err != nil {
|
if err := uiTemplate.Execute(&buf, data); err != nil {
|
||||||
|
@ -94,9 +97,9 @@ type Pagination struct {
|
||||||
HasPrev bool
|
HasPrev bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// goCommits gets a slice of the latest Commits to the Go repository.
|
// dashCommits gets a slice of the latest Commits to the current dashboard.
|
||||||
// If page > 0 it paginates by commitsPerPage.
|
// If page > 0 it paginates by commitsPerPage.
|
||||||
func goCommits(c appengine.Context, page int) ([]*Commit, error) {
|
func dashCommits(c appengine.Context, page int) ([]*Commit, error) {
|
||||||
q := datastore.NewQuery("Commit").
|
q := datastore.NewQuery("Commit").
|
||||||
Ancestor((&Package{}).Key(c)).
|
Ancestor((&Package{}).Key(c)).
|
||||||
Order("-Num").
|
Order("-Num").
|
||||||
|
@ -166,6 +169,7 @@ func TagStateByName(c appengine.Context, name string) (*TagState, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type uiTemplateData struct {
|
type uiTemplateData struct {
|
||||||
|
Dashboard *Dashboard
|
||||||
Commits []*Commit
|
Commits []*Commit
|
||||||
Builders []string
|
Builders []string
|
||||||
TipState *TagState
|
TipState *TagState
|
||||||
|
@ -183,6 +187,7 @@ var tmplFuncs = template.FuncMap{
|
||||||
"builderArchChar": builderArchChar,
|
"builderArchChar": builderArchChar,
|
||||||
"builderTitle": builderTitle,
|
"builderTitle": builderTitle,
|
||||||
"builderSpans": builderSpans,
|
"builderSpans": builderSpans,
|
||||||
|
"buildDashboards": buildDashboards,
|
||||||
"repoURL": repoURL,
|
"repoURL": repoURL,
|
||||||
"shortDesc": shortDesc,
|
"shortDesc": shortDesc,
|
||||||
"shortHash": shortHash,
|
"shortHash": shortHash,
|
||||||
|
@ -265,6 +270,11 @@ func builderTitle(s string) string {
|
||||||
return strings.Replace(s, "-", " ", -1)
|
return strings.Replace(s, "-", " ", -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildDashboards returns the known public dashboards.
|
||||||
|
func buildDashboards() []*Dashboard {
|
||||||
|
return dashboards
|
||||||
|
}
|
||||||
|
|
||||||
// shortDesc returns the first line of a description.
|
// shortDesc returns the first line of a description.
|
||||||
func shortDesc(desc string) string {
|
func shortDesc(desc string) string {
|
||||||
if i := strings.Index(desc, "\n"); i != -1 {
|
if i := strings.Index(desc, "\n"); i != -1 {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<!DOCTYPE HTML>
|
<!DOCTYPE HTML>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Go Build Dashboard</title>
|
<title>{{$.Dashboard.Name}} Build Dashboard</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
|
@ -53,10 +53,10 @@
|
||||||
.build .desc, .build .time, .build .user {
|
.build .desc, .build .time, .build .user {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.paginate {
|
.dashboards, .paginate {
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
}
|
}
|
||||||
.paginate a {
|
.dashboards a, .paginate a {
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
background: #eee;
|
background: #eee;
|
||||||
color: blue;
|
color: blue;
|
||||||
|
@ -70,8 +70,12 @@
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<h1>{{$.Dashboard.Name}} Build Status</h1>
|
||||||
<h1>Go Build Status</h1>
|
<nav class="dashboards">
|
||||||
|
{{range buildDashboards}}
|
||||||
|
<a href="{{.RelPath}}">{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
|
||||||
{{if $.Commits}}
|
{{if $.Commits}}
|
||||||
|
|
||||||
|
@ -138,12 +142,13 @@
|
||||||
|
|
||||||
{{with $.TipState}}
|
{{with $.TipState}}
|
||||||
{{$goHash := .Tag.Hash}}
|
{{$goHash := .Tag.Hash}}
|
||||||
<h2>
|
{{if .Packages}}
|
||||||
Sub-repositories at tip
|
<h2>
|
||||||
<small>(<a href="{{repoURL .Tag.Hash ""}}">{{shortHash .Tag.Hash}}</a>)</small>
|
Sub-repositories at tip
|
||||||
</h2>
|
<small>(<a href="{{repoURL .Tag.Hash ""}}">{{shortHash .Tag.Hash}}</a>)</small>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<table class="build">
|
<table class="build">
|
||||||
<colgroup class="col-package"></colgroup>
|
<colgroup class="col-package"></colgroup>
|
||||||
<colgroup class="col-hash"></colgroup>
|
<colgroup class="col-hash"></colgroup>
|
||||||
{{range $.Builders | builderSpans}}
|
{{range $.Builders | builderSpans}}
|
||||||
|
@ -203,6 +208,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</table>
|
</table>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
Loading…
Reference in New Issue