cmd/relui: enable creation of mock workflow
This change introduces mock data, multiple templates, and an in-memory
store to the release automation webserver. The goal of this change is to
introduce a basic UI structure. The underlying data infrastructure is
only for mock purposes, and will be replaced in a future CL.
This change enables creation and viewing of an in-memory workflow, with
very minor data stored.
List screenshot:
https://storage.googleapis.com/screen.toothrot.net/pub/2020-07-17-workflow-list.png
New workflow screenshot:
https://storage.googleapis.com/screen.toothrot.net/pub/2020-07-01-11_23_17-workflows-new.png
For golang/go#40279
Change-Id: Id9dfcc01cb2aba1df3e36d7a6301bbf8b47476da
Reviewed-on: https://go-review.googlesource.com/c/build/+/243339
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Andrew Bonventre <andybons@golang.org>
diff --git a/cmd/relui/index.html b/cmd/relui/index.html
deleted file mode 100644
index 0c7de90..0000000
--- a/cmd/relui/index.html
+++ /dev/null
@@ -1,35 +0,0 @@
-<!--
- Copyright 2020 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.
--->
-<!DOCTYPE html>
-<html lang="en">
- <title>Go Releases</title>
- <meta name="viewport" content="width=device-width, initial-scale=1" />
- <link rel="stylesheet" href="/styles.css" />
- <body class="Site">
- <header class="Site-header">
- <div class="Header">
- <h1 class="Header-title">Go Releases</h1>
- </div>
- </header>
- <main class="Site-content">
- <section class="Workflows">
- <h2>Workflows</h2>
- <ul class="WorkflowList">
- <li class="WorkflowList-item">
- <h3>Local Release - 20a838ab</h3>
- <form> </form>
- <ul class="TaskList">
- <li class="TaskList-item">
- <h4>Fetch blob - 20a838ab</h4>
- Status: created
- </li>
- </ul>
- </li>
- </ul>
- </section>
- </main>
- </body>
-</html>
diff --git a/cmd/relui/main.go b/cmd/relui/main.go
index 57e52bb..e20c6fb 100644
--- a/cmd/relui/main.go
+++ b/cmd/relui/main.go
@@ -11,7 +11,10 @@
)
func main() {
- http.Handle("/", fileServerHandler(relativeFile("./static"), http.HandlerFunc(homeHandler)))
+ s := &server{store: &memoryStore{}}
+ http.Handle("/workflows/create", http.HandlerFunc(s.createWorkflowHandler))
+ http.Handle("/workflows/new", http.HandlerFunc(s.newWorkflowHandler))
+ http.Handle("/", fileServerHandler(relativeFile("./static"), http.HandlerFunc(s.homeHandler)))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
diff --git a/cmd/relui/static/styles.css b/cmd/relui/static/styles.css
index 3d6217a..e92e8e2 100644
--- a/cmd/relui/static/styles.css
+++ b/cmd/relui/static/styles.css
@@ -51,3 +51,68 @@
font-size: 1.5rem;
margin: 0;
}
+@media only screen and (min-width: 75rem) {
+ .Workflows,
+ .NewWorkflow {
+ width: 74.75rem;
+ }
+}
+@media only screen and (min-width: 48rem) {
+ .Workflows,
+ .NewWorkflow {
+ margin: 0 auto;
+ }
+}
+.Workflows-header {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+}
+.WorkflowList,
+.TaskList {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.WorkflowList-sectionTitle {
+ margin-bottom: 0.5rem;
+ font-weight: normal;
+}
+.TaskList {
+ border: 1px solid #d6d6d6;
+ border-radius: 0.25rem;
+}
+.TaskList-item {
+ display: flex;
+ align-items: center;
+ padding: 0.5rem;
+ justify-content: space-between;
+}
+.TaskList-item + .TaskList-item {
+ border-top: 0.0625rem solid #d6d6d6;
+}
+.Button {
+ background: #375eab;
+ border-radius: 0.1875rem;
+ box-shadow: 0 0.1875rem 0.0625rem -0.125rem rgba(0, 0, 0, 0.2),
+ 0 0.125rem 0.125rem 0 rgba(0, 0, 0, 0.14),
+ 0 0.0625rem 0.3125rem 0 rgba(0, 0, 0, 0.12);
+ color: #fff;
+ font-size: 0.875rem;
+ min-width: 4rem;
+ padding: 0.5rem 1rem;
+ text-decoration: none;
+}
+.Button:hover,
+.Button:focus {
+ background: #3b65b3;
+ box-shadow: 0 0.125rem 0.25rem -0.0625rem rgba(0, 0, 0, 0.2),
+ 0 0.25rem 0.3125rem 0 rgba(0, 0, 0, 0.14),
+ 0 0.0625rem 0.625rem 0 rgba(0, 0, 0, 0.12);
+}
+.Button:active {
+ background: #4373cc;
+ box-shadow: 0 0.3125rem 0.3125rem -0.1875rem rgba(0, 0, 0, 0.2),
+ 0 0.5rem 0.625rem 0.0625rem rgba(0, 0, 0, 0.14),
+ 0 0.1875rem 0.875rem 0.125rem rgba(0, 0, 0, 0.12);
+}
diff --git a/cmd/relui/templates/home.html b/cmd/relui/templates/home.html
new file mode 100644
index 0000000..7cd9342
--- /dev/null
+++ b/cmd/relui/templates/home.html
@@ -0,0 +1,33 @@
+<!--
+ Copyright 2020 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.
+-->
+{{define "content"}}
+<section class="Workflows">
+ <div class="Workflows-header">
+ <h2>Workflows</h2>
+ <a href="/workflows/new" class="Button">New</a>
+ </div>
+ <ul class="WorkflowList">
+ {{range $workflow := .Workflows}}
+ <li class="WorkflowList-item">
+ <h3>{{$workflow.Title}}</h3>
+ <h4 class="WorkflowList-sectionTitle">Tasks</h4>
+ <ul class="TaskList">
+ {{range $task := $workflow.Tasks}}
+ <li class="TaskList-item">
+ <span class="TaskList-itemTitle">{{$task.Title}}</span>
+ Status: {{$task.Status}}
+ </li>
+ {{end}}
+ <li class="TaskList-item">
+ <span class="TaskList-itemTitle">Sample Task</span>
+ Status: created
+ </li>
+ </ul>
+ </li>
+ {{end}}
+ </ul>
+</section>
+{{end}}
diff --git a/cmd/relui/templates/layout.html b/cmd/relui/templates/layout.html
new file mode 100644
index 0000000..03b892b
--- /dev/null
+++ b/cmd/relui/templates/layout.html
@@ -0,0 +1,21 @@
+<!--
+ Copyright 2020 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.
+-->
+<!DOCTYPE html>
+<html lang="en">
+ <title>Go Releases</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <link rel="stylesheet" href="/styles.css" />
+ <body class="Site">
+ <header class="Site-header">
+ <div class="Header">
+ <h1 class="Header-title">Go Releases</h1>
+ </div>
+ </header>
+ <main class="Site-content">
+ {{template "content" .}}
+ </main>
+ </body>
+</html>
diff --git a/cmd/relui/templates/new_workflow.html b/cmd/relui/templates/new_workflow.html
new file mode 100644
index 0000000..227e493
--- /dev/null
+++ b/cmd/relui/templates/new_workflow.html
@@ -0,0 +1,17 @@
+<!--
+ Copyright 2020 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.
+-->
+{{define "content"}}
+<section class="NewWorkflow">
+ <h2>New Go Release</h2>
+ <form action="/workflows/create" method="post">
+ <label>
+ Revision
+ <input name="workflow.revision" value="master" />
+ </label>
+ <input name="workflow.create" type="submit" value="Create" />
+ </form>
+</section>
+{{end}}
diff --git a/cmd/relui/web.go b/cmd/relui/web.go
index 40cd773..ec24176 100644
--- a/cmd/relui/web.go
+++ b/cmd/relui/web.go
@@ -5,7 +5,9 @@
package main
import (
+ "bytes"
"html/template"
+ "io"
"log"
"mime"
"net/http"
@@ -14,12 +16,17 @@
"path/filepath"
)
+// fileServerHandler returns a http.Handler rooted at root. It will call the next handler provided for requests to "/".
+//
+// The returned handler sets the appropriate Content-Type and Cache-Control headers for the returned file.
func fileServerHandler(root string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
next.ServeHTTP(w, r)
return
}
+ // http.FileServer would correctly return a 404, but we need to check that the file exists
+ // before calculating the Content-Type header.
if _, err := os.Stat(path.Join(root, r.URL.Path)); os.IsNotExist(err) {
http.NotFound(w, r)
return
@@ -32,13 +39,60 @@
})
}
-var homeTemplate = template.Must(template.ParseFiles(relativeFile("index.html")))
+var (
+ homeTmpl = template.Must(template.Must(layoutTmpl.Clone()).ParseFiles(relativeFile("templates/home.html")))
+ layoutTmpl = template.Must(template.ParseFiles(relativeFile("templates/layout.html")))
+ newWorkflowTmpl = template.Must(template.Must(layoutTmpl.Clone()).ParseFiles(relativeFile("templates/new_workflow.html")))
+)
-func homeHandler(w http.ResponseWriter, _ *http.Request) {
- if err := homeTemplate.Execute(w, nil); err != nil {
- log.Printf("homeHandlerFunc: %v", err)
+// server implements the http handlers for relui.
+type server struct {
+ store store
+}
+
+type homeResponse struct {
+ Workflows []workflow
+}
+
+// homeHandler renders the homepage.
+func (s *server) homeHandler(w http.ResponseWriter, _ *http.Request) {
+ out := bytes.Buffer{}
+ if err := homeTmpl.Execute(&out, homeResponse{Workflows: s.store.GetWorkflows()}); err != nil {
+ log.Printf("homeHandler: %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
}
+ io.Copy(w, &out)
+}
+
+// newWorkflowHandler presents a form for creating a new workflow.
+func (s *server) newWorkflowHandler(w http.ResponseWriter, _ *http.Request) {
+ out := bytes.Buffer{}
+ if err := newWorkflowTmpl.Execute(&out, nil); err != nil {
+ log.Printf("newWorkflowHandler: %v", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ io.Copy(w, &out)
+}
+
+// createWorkflowHandler persists a new workflow in the datastore.
+func (s *server) createWorkflowHandler(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+ ref := r.Form.Get("workflow.revision")
+ if ref == "" {
+ // TODO(golang.org/issue/40279) - render a better error in the form.
+ http.Error(w, "workflow revision is required", http.StatusBadRequest)
+ return
+ }
+ if err := s.store.AddWorkflow(newLocalGoRelease(ref)); err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/", http.StatusSeeOther)
}
// relativeFile returns the path to the provided file or directory,
diff --git a/cmd/relui/web_test.go b/cmd/relui/web_test.go
index 1649e2d..dfb739a 100644
--- a/cmd/relui/web_test.go
+++ b/cmd/relui/web_test.go
@@ -8,21 +8,13 @@
"io/ioutil"
"net/http"
"net/http/httptest"
+ "net/url"
+ "strings"
"testing"
+
+ "github.com/google/go-cmp/cmp"
)
-func TestHomeHandler(t *testing.T) {
- req := httptest.NewRequest("GET", "/", nil)
- w := httptest.NewRecorder()
-
- homeHandler(w, req)
- resp := w.Result()
-
- if resp.StatusCode != http.StatusOK {
- t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
- }
-}
-
func TestFileServerHandler(t *testing.T) {
h := fileServerHandler("./testing", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Home"))
@@ -60,7 +52,7 @@
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
- req := httptest.NewRequest("GET", c.path, nil)
+ req := httptest.NewRequest(http.MethodGet, c.path, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
@@ -85,3 +77,84 @@
})
}
}
+
+func TestServerHomeHandler(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ w := httptest.NewRecorder()
+
+ s := &server{store: &memoryStore{}}
+ s.homeHandler(w, req)
+ resp := w.Result()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("resp.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
+ }
+}
+
+func TestServerNewWorkflowHandler(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/workflows/new", nil)
+ w := httptest.NewRecorder()
+
+ s := &server{store: &memoryStore{}}
+ s.newWorkflowHandler(w, req)
+ resp := w.Result()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
+ }
+}
+
+func TestServerCreateWorkflowHandler(t *testing.T) {
+ cases := []struct {
+ desc string
+ params url.Values
+ wantCode int
+ wantHeaders map[string]string
+ wantParams map[string]string
+ }{
+ {
+ desc: "bad request",
+ wantCode: http.StatusBadRequest,
+ },
+ {
+ desc: "successful creation",
+ params: url.Values{"workflow.revision": []string{"abc"}},
+ wantCode: http.StatusSeeOther,
+ wantHeaders: map[string]string{
+ "Location": "/",
+ },
+ wantParams: map[string]string{"GitObject": "abc"},
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.desc, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/workflows/create", strings.NewReader(c.params.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+
+ s := &server{store: &memoryStore{}}
+ s.createWorkflowHandler(w, req)
+ resp := w.Result()
+
+ if resp.StatusCode != c.wantCode {
+ t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
+ }
+ for k, v := range c.wantHeaders {
+ if resp.Header.Get(k) != v {
+ t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
+ }
+ }
+ if len(s.store.GetWorkflows()) != 1 && c.wantParams != nil {
+ t.Fatalf("len(s.store.GetWorkflows()) = %d, wanted %d", len(s.store.GetWorkflows()), 1)
+ } else if len(s.store.GetWorkflows()) != 0 && c.wantParams == nil {
+ t.Fatalf("len(s.store.GetWorkflows()) = %d, wanted %d", len(s.store.GetWorkflows()), 0)
+ }
+ if c.wantParams == nil {
+ return
+ }
+ if diff := cmp.Diff(c.wantParams, s.store.GetWorkflows()[0].Params()); diff != "" {
+ t.Errorf("s.Store.GetWorkflows()[0].Params() mismatch (-want, +got):\n%s", diff)
+ }
+ })
+ }
+}
diff --git a/cmd/relui/workflow.go b/cmd/relui/workflow.go
new file mode 100644
index 0000000..bc96725
--- /dev/null
+++ b/cmd/relui/workflow.go
@@ -0,0 +1,88 @@
+// Copyright 2020 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 main
+
+import (
+ "fmt"
+ "sync"
+)
+
+type workflow interface {
+ // Params are the list of parameters given when the workflow was created.
+ Params() map[string]string
+ // Title is a human-readable description of a task.
+ Title() string
+ // Tasks are a list of steps in a workflow.
+ Tasks() []task
+}
+
+type task interface {
+ // Title is a human-readable description of a task.
+ Title() string
+ // Status is the current status of the task.
+ Status() string
+}
+
+// newLocalGoRelease creates a localGoRelease workflow.
+func newLocalGoRelease(revision string) *localGoRelease {
+ return &localGoRelease{GitObject: revision, tasks: []task{&fetchGoSource{gitObject: revision}}}
+}
+
+type localGoRelease struct {
+ GitObject string
+ tasks []task
+}
+
+func (l *localGoRelease) Params() map[string]string {
+ return map[string]string{"GitObject": l.GitObject}
+}
+
+func (l *localGoRelease) Title() string {
+ return fmt.Sprintf("Local Go release (%s)", l.GitObject)
+}
+
+func (l *localGoRelease) Tasks() []task {
+ return l.tasks
+}
+
+// fetchGoSource is a task for fetching the Go repository at a specific commit reference.
+type fetchGoSource struct {
+ gitObject string
+}
+
+func (f *fetchGoSource) Title() string {
+ return "Fetch Go source at " + f.gitObject
+}
+
+func (f *fetchGoSource) Status() string {
+ return "created"
+}
+
+// store is a persistence adapter for saving data. When running locally, this is implemented by memoryStore.
+type store interface {
+ GetWorkflows() []workflow
+ AddWorkflow(workflow) error
+}
+
+// memoryStore is a non-durable implementation of store that keeps everything in memory.
+type memoryStore struct {
+ sync.Mutex
+ Workflows []workflow
+}
+
+// AddWorkflow adds a workflow to the store.
+func (m *memoryStore) AddWorkflow(w workflow) error {
+ m.Lock()
+ defer m.Unlock()
+ m.Workflows = append(m.Workflows, w)
+ return nil
+}
+
+// GetWorkflows returns all workflows stored.
+func (m *memoryStore) GetWorkflows() []workflow {
+ m.Lock()
+ defer m.Unlock()
+ return m.Workflows
+}