internal/relui: persist logs to database
Save log output from workflow tasks in the database. Render logs in the
UI alongside each task. This will almost certainly be a performance
problem if we have many logs until we implement pagination.
For golang/go#47401
Change-Id: I4ddc3d7845e4559cc636b3b7972e0adefe9fcec4
Reviewed-on: https://go-review.googlesource.com/c/build/+/353170
Trust: Alexander Rakoczy <alex@golang.org>
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
diff --git a/cmd/relui/Makefile b/cmd/relui/Makefile
index 82e1b56..f7c75ae 100644
--- a/cmd/relui/Makefile
+++ b/cmd/relui/Makefile
@@ -33,7 +33,7 @@
schema.sql: $(MIGRATION_FILES) $(GO_FILES)
docker build -f Dockerfile -t golang/relui:$(VERSION) ../..
- docker run --rm --name=relui-dev-migrate -v $(POSTGRES_RUN_DEV) $(DOCKER_TAG) --only-migrate
+ docker run --rm --name=relui-dev-migrate -v $(POSTGRES_RUN_DEV) -e PGUSER=$(POSTGRES_USER) -e PGDATABASE=relui-dev $(DOCKER_TAG) --only-migrate
docker exec postgres-dev pg_dump --username=$(POSTGRES_USER) --schema-only relui-dev > schema.sql
.PHONY: test
diff --git a/cmd/relui/schema.sql b/cmd/relui/schema.sql
index 7326ab2..e1394b0 100644
--- a/cmd/relui/schema.sql
+++ b/cmd/relui/schema.sql
@@ -33,6 +33,44 @@
ALTER TABLE public.migrations OWNER TO postgres;
--
+-- Name: task_logs; Type: TABLE; Schema: public; Owner: postgres
+--
+
+CREATE TABLE public.task_logs (
+ id integer NOT NULL,
+ workflow_id uuid NOT NULL,
+ task_name text NOT NULL,
+ body text NOT NULL,
+ created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
+);
+
+
+ALTER TABLE public.task_logs OWNER TO postgres;
+
+--
+-- Name: task_logs_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres
+--
+
+CREATE SEQUENCE public.task_logs_id_seq
+ AS integer
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+ALTER TABLE public.task_logs_id_seq OWNER TO postgres;
+
+--
+-- Name: task_logs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres
+--
+
+ALTER SEQUENCE public.task_logs_id_seq OWNED BY public.task_logs.id;
+
+
+--
-- Name: tasks; Type: TABLE; Schema: public; Owner: postgres
--
@@ -65,6 +103,13 @@
ALTER TABLE public.workflows OWNER TO postgres;
--
+-- Name: task_logs id; Type: DEFAULT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.task_logs ALTER COLUMN id SET DEFAULT nextval('public.task_logs_id_seq'::regclass);
+
+
+--
-- Name: migrations migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
@@ -73,6 +118,14 @@
--
+-- Name: task_logs task_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.task_logs
+ ADD CONSTRAINT task_logs_pkey PRIMARY KEY (id);
+
+
+--
-- Name: tasks tasks_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
--
@@ -89,6 +142,14 @@
--
+-- Name: task_logs task_logs_workflow_id_task_name_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
+--
+
+ALTER TABLE ONLY public.task_logs
+ ADD CONSTRAINT task_logs_workflow_id_task_name_fkey FOREIGN KEY (workflow_id, task_name) REFERENCES public.tasks(workflow_id, name);
+
+
+--
-- Name: tasks tasks_workflow_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
--
diff --git a/internal/relui/db/models.go b/internal/relui/db/models.go
index d5027b2..8342e7a 100644
--- a/internal/relui/db/models.go
+++ b/internal/relui/db/models.go
@@ -24,6 +24,15 @@
UpdatedAt time.Time
}
+type TaskLog struct {
+ ID int32
+ WorkflowID uuid.UUID
+ TaskName string
+ Body string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
type Workflow struct {
ID uuid.UUID
Params sql.NullString
diff --git a/internal/relui/db/workflows.sql.go b/internal/relui/db/workflows.sql.go
index f665e34..cb219ed 100644
--- a/internal/relui/db/workflows.sql.go
+++ b/internal/relui/db/workflows.sql.go
@@ -50,6 +50,32 @@
return i, err
}
+const createTaskLog = `-- name: CreateTaskLog :one
+INSERT INTO task_logs (workflow_id, task_name, body)
+VALUES ($1, $2, $3)
+RETURNING id, workflow_id, task_name, body, created_at, updated_at
+`
+
+type CreateTaskLogParams struct {
+ WorkflowID uuid.UUID
+ TaskName string
+ Body string
+}
+
+func (q *Queries) CreateTaskLog(ctx context.Context, arg CreateTaskLogParams) (TaskLog, error) {
+ row := q.db.QueryRow(ctx, createTaskLog, arg.WorkflowID, arg.TaskName, arg.Body)
+ var i TaskLog
+ err := row.Scan(
+ &i.ID,
+ &i.WorkflowID,
+ &i.TaskName,
+ &i.Body,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
const createWorkflow = `-- name: CreateWorkflow :one
INSERT INTO workflows (id, params, name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
@@ -83,6 +109,78 @@
return i, err
}
+const taskLogs = `-- name: TaskLogs :many
+SELECT task_logs.id, task_logs.workflow_id, task_logs.task_name, task_logs.body, task_logs.created_at, task_logs.updated_at
+FROM task_logs
+ORDER BY created_at
+`
+
+func (q *Queries) TaskLogs(ctx context.Context) ([]TaskLog, error) {
+ rows, err := q.db.Query(ctx, taskLogs)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []TaskLog
+ for rows.Next() {
+ var i TaskLog
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkflowID,
+ &i.TaskName,
+ &i.Body,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const taskLogsForTask = `-- name: TaskLogsForTask :many
+SELECT task_logs.id, task_logs.workflow_id, task_logs.task_name, task_logs.body, task_logs.created_at, task_logs.updated_at
+FROM task_logs
+WHERE workflow_id=$1 AND task_name = $2
+ORDER BY created_at
+`
+
+type TaskLogsForTaskParams struct {
+ WorkflowID uuid.UUID
+ TaskName string
+}
+
+func (q *Queries) TaskLogsForTask(ctx context.Context, arg TaskLogsForTaskParams) ([]TaskLog, error) {
+ rows, err := q.db.Query(ctx, taskLogsForTask, arg.WorkflowID, arg.TaskName)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []TaskLog
+ for rows.Next() {
+ var i TaskLog
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkflowID,
+ &i.TaskName,
+ &i.Body,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const tasks = `-- name: Tasks :many
SELECT tasks.workflow_id, tasks.name, tasks.finished, tasks.result, tasks.error, tasks.created_at, tasks.updated_at
FROM tasks
@@ -121,7 +219,7 @@
SELECT tasks.workflow_id, tasks.name, tasks.finished, tasks.result, tasks.error, tasks.created_at, tasks.updated_at
FROM tasks
WHERE workflow_id=$1
-ORDER BY created_At
+ORDER BY created_at
`
func (q *Queries) TasksForWorkflow(ctx context.Context, workflowID uuid.UUID) ([]Task, error) {
@@ -217,11 +315,15 @@
}
const workflows = `-- name: Workflows :many
+
SELECT id, params, name, created_at, updated_at
FROM workflows
ORDER BY created_at DESC
`
+// Copyright 2021 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.
func (q *Queries) Workflows(ctx context.Context) ([]Workflow, error) {
rows, err := q.db.Query(ctx, workflows)
if err != nil {
diff --git a/internal/relui/listener.go b/internal/relui/listener.go
new file mode 100644
index 0000000..8097ac4
--- /dev/null
+++ b/internal/relui/listener.go
@@ -0,0 +1,91 @@
+// Copyright 2021 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 relui
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v4"
+ "github.com/jackc/pgx/v4/pgxpool"
+ "golang.org/x/build/internal/relui/db"
+ "golang.org/x/build/internal/workflow"
+)
+
+// listener implements workflow.Listener for recording workflow state.
+type listener struct {
+ db *pgxpool.Pool
+}
+
+// TaskStateChanged is called whenever a task is updated by the
+// workflow. The workflow.TaskState is persisted as a db.Task,
+// creating or updating a row as necessary.
+func (l *listener) TaskStateChanged(workflowID uuid.UUID, taskName string, state *workflow.TaskState) error {
+ log.Printf("TaskStateChanged(%q, %q, %v)", workflowID, taskName, state)
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ result, err := json.Marshal(state.Result)
+ if err != nil {
+ return err
+ }
+ err = l.db.BeginFunc(ctx, func(tx pgx.Tx) error {
+ q := db.New(tx)
+ updated := time.Now()
+ _, err := q.UpsertTask(ctx, db.UpsertTaskParams{
+ WorkflowID: workflowID,
+ Name: taskName,
+ Finished: state.Finished,
+ Result: sql.NullString{String: string(result), Valid: len(result) > 0},
+ Error: sql.NullString{},
+ CreatedAt: updated,
+ UpdatedAt: updated,
+ })
+ return err
+ })
+ if err != nil {
+ log.Printf("TaskStateChanged(%q, %q, %v) = %v", workflowID, taskName, state, err)
+ }
+ return err
+}
+
+func (l *listener) Logger(workflowID uuid.UUID, taskName string) workflow.Logger {
+ return &postgresLogger{
+ db: l.db,
+ workflowID: workflowID,
+ taskName: taskName,
+ }
+}
+
+// postgresLogger logs task output to the database. It implements workflow.Logger.
+type postgresLogger struct {
+ db *pgxpool.Pool
+ workflowID uuid.UUID
+ taskName string
+}
+
+func (l *postgresLogger) Printf(format string, v ...interface{}) {
+ ctx := context.Background()
+ err := l.db.BeginFunc(ctx, func(tx pgx.Tx) error {
+ q := db.New(tx)
+ body := fmt.Sprintf(format, v...)
+ _, err := q.CreateTaskLog(ctx, db.CreateTaskLogParams{
+ WorkflowID: l.workflowID,
+ TaskName: l.taskName,
+ Body: body,
+ })
+ if err != nil {
+ log.Printf("q.CreateTaskLog(%v, %v, %q) = %v", l.workflowID, l.taskName, body, err)
+ }
+ return err
+ })
+ if err != nil {
+ log.Printf("l.Printf(%q, %v) = %v", format, v, err)
+ }
+}
diff --git a/internal/relui/listener_test.go b/internal/relui/listener_test.go
new file mode 100644
index 0000000..468ea34
--- /dev/null
+++ b/internal/relui/listener_test.go
@@ -0,0 +1,95 @@
+// Copyright 2021 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 relui
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "golang.org/x/build/internal/relui/db"
+ "golang.org/x/build/internal/workflow"
+)
+
+func TestListenerTaskStateChanged(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ dbp := testDB(ctx, t)
+ q := db.New(dbp)
+ wfp := db.CreateWorkflowParams{ID: uuid.New()}
+ wf, err := q.CreateWorkflow(ctx, wfp)
+ if err != nil {
+ t.Fatalf("q.CreateWorkflow(%v, %v) = %v, wanted no error", ctx, wfp, err)
+ }
+
+ l := &listener{db: dbp}
+ state := &workflow.TaskState{
+ Name: "TestTask",
+ Finished: true,
+ Result: struct{ Value int }{5},
+ SerializedResult: []byte(`{"Value": 5}`),
+ Error: "",
+ }
+ err = l.TaskStateChanged(wf.ID, "TestTask", state)
+ if err != nil {
+ t.Fatalf("l.TaskStateChanged(%v, %q, %v) = %v, wanted no error", wf.ID, "TestTask", state, err)
+ }
+
+ tasks, err := q.TasksForWorkflow(ctx, wf.ID)
+ if err != nil {
+ t.Fatalf("q.TasksForWorkflow(%v, %v) = %v, %v, wanted no error", ctx, wf.ID, tasks, err)
+ }
+ want := []db.Task{{
+ WorkflowID: wf.ID,
+ Name: "TestTask",
+ Finished: true,
+ Result: sql.NullString{String: `{"Value": 5}`, Valid: true},
+ CreatedAt: time.Now(), // cmpopts.EquateApproxTime
+ UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
+ }}
+ if diff := cmp.Diff(want, tasks, cmpopts.EquateApproxTime(time.Minute)); diff != "" {
+ t.Errorf("q.TasksForWorkflow(_, %q) mismatch (-want +got):\n%s", wf.ID, diff)
+ }
+}
+
+func TestListenerLogger(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ dbp := testDB(ctx, t)
+ q := db.New(dbp)
+
+ wfp := db.CreateWorkflowParams{ID: uuid.New()}
+ wf, err := q.CreateWorkflow(ctx, wfp)
+ if err != nil {
+ t.Fatalf("q.CreateWorkflow(%v, %v) = %v, wanted no error", ctx, wfp, err)
+ }
+ params := db.UpsertTaskParams{WorkflowID: wf.ID, Name: "TestTask"}
+ _, err = q.UpsertTask(ctx, params)
+ if err != nil {
+ t.Fatalf("q.UpsertTask(%v, %v) = %v, wanted no error", ctx, params, err)
+ }
+
+ l := &listener{db: dbp}
+ l.Logger(wf.ID, "TestTask").Printf("A fancy log line says %q", "hello")
+
+ logs, err := q.TaskLogs(ctx)
+ if err != nil {
+ t.Fatalf("q.TaskLogs(%v) = %v, wanted no error", ctx, err)
+ }
+ want := []db.TaskLog{{
+ WorkflowID: wf.ID,
+ TaskName: "TestTask",
+ Body: `A fancy log line says "hello"`,
+ CreatedAt: time.Now(), // cmpopts.EquateApproxTime
+ UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
+ }}
+ if diff := cmp.Diff(want, logs, cmpopts.EquateApproxTime(time.Minute), cmpopts.IgnoreFields(db.TaskLog{}, "ID")); diff != "" {
+ t.Errorf("q.TaskLogs(_, %q) mismatch (-want +got):\n%s", wf.ID, diff)
+ }
+}
diff --git a/internal/relui/migrations/20210909141405_create_workflows_table.up.sql b/internal/relui/migrations/20210909141405_create_workflows_table.up.sql
index 198f813..080468f 100644
--- a/internal/relui/migrations/20210909141405_create_workflows_table.up.sql
+++ b/internal/relui/migrations/20210909141405_create_workflows_table.up.sql
@@ -16,7 +16,7 @@
finished bool NOT NULL DEFAULT false,
result jsonb,
error text,
- created_at timestamp with time zone NOT NULl default current_timestamp,
+ created_at timestamp with time zone NOT NULL default current_timestamp,
updated_at timestamp with time zone NOT NULL default current_timestamp,
PRIMARY KEY (workflow_id, name)
);
diff --git a/internal/relui/migrations/20210928195553_create_task_logs_table.down.sql b/internal/relui/migrations/20210928195553_create_task_logs_table.down.sql
new file mode 100644
index 0000000..f914f38
--- /dev/null
+++ b/internal/relui/migrations/20210928195553_create_task_logs_table.down.sql
@@ -0,0 +1,5 @@
+-- Copyright 2021 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.
+
+DROP TABLE task_logs;
diff --git a/internal/relui/migrations/20210928195553_create_task_logs_table.up.sql b/internal/relui/migrations/20210928195553_create_task_logs_table.up.sql
new file mode 100644
index 0000000..226bb00
--- /dev/null
+++ b/internal/relui/migrations/20210928195553_create_task_logs_table.up.sql
@@ -0,0 +1,13 @@
+-- Copyright 2021 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.
+
+CREATE TABLE task_logs (
+ id SERIAL PRIMARY KEY,
+ workflow_id uuid NOT NULL,
+ task_name text NOT NULL,
+ body text NOT NULL,
+ created_at timestamp with time zone NOT NULL default current_timestamp,
+ updated_at timestamp with time zone NOT NULL default current_timestamp,
+ FOREIGN KEY (workflow_id, task_name) REFERENCES tasks (workflow_id, name)
+);
diff --git a/internal/relui/queries/workflows.sql b/internal/relui/queries/workflows.sql
index fe58679..a197363 100644
--- a/internal/relui/queries/workflows.sql
+++ b/internal/relui/queries/workflows.sql
@@ -42,4 +42,20 @@
SELECT tasks.*
FROM tasks
WHERE workflow_id=$1
-ORDER BY created_At;
+ORDER BY created_at;
+
+-- name: CreateTaskLog :one
+INSERT INTO task_logs (workflow_id, task_name, body)
+VALUES ($1, $2, $3)
+RETURNING *;
+
+-- name: TaskLogsForTask :many
+SELECT task_logs.*
+FROM task_logs
+WHERE workflow_id=$1 AND task_name = $2
+ORDER BY created_at;
+
+-- name: TaskLogs :many
+SELECT task_logs.*
+FROM task_logs
+ORDER BY created_at;
diff --git a/internal/relui/static/styles.css b/internal/relui/static/styles.css
index e92e8e2..0a2ba88 100644
--- a/internal/relui/static/styles.css
+++ b/internal/relui/static/styles.css
@@ -36,7 +36,6 @@
}
.Site-content {
flex: 1 0 auto;
- padding: 0.625rem;
width: 100%;
}
.Site-header {
@@ -51,22 +50,17 @@
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 {
+ padding: 0 0.625rem;
}
.Workflows-header {
align-items: center;
+ background: #fff;
+ border-bottom: 0.0625rem solid #d6d6d6;
display: flex;
justify-content: space-between;
+ margin: 0 -0.625rem;
+ padding: 0 0.625rem;
}
.WorkflowList,
.TaskList {
@@ -74,23 +68,49 @@
margin: 0;
padding: 0;
}
+.WorkflowList-title {
+}
.WorkflowList-sectionTitle {
- margin-bottom: 0.5rem;
font-weight: normal;
+ margin-bottom: 0.5rem;
+}
+.WorkflowList-item {
+ background: #fff;
+ margin-top: 1rem;
+ padding: 0 0.5rem;
+ border: 0.0625rem solid #d6d6d6;
+ border-radius: 0.0625rem;
}
.TaskList {
- border: 1px solid #d6d6d6;
- border-radius: 0.25rem;
-}
-.TaskList-item {
- display: flex;
- align-items: center;
- padding: 0.5rem;
- justify-content: space-between;
+ border-bottom: 0.0625rem solid #d6d6d6;
+ border-top: 0.0625rem solid #d6d6d6;
+ margin-bottom: 1rem;
}
.TaskList-item + .TaskList-item {
border-top: 0.0625rem solid #d6d6d6;
}
+.TaskList-itemSummary {
+ align-items: center;
+ cursor: pointer;
+ justify-content: space-between;
+ padding: 0.5rem;
+}
+.TaskList-itemSummary:hover {
+ background-color: #fafafa;
+}
+.TaskList-itemLogs {
+ background-color: #f5f5f5;
+ box-shadow: inset 0 6px 6px -8px #888;
+ font-size: 0.875rem;
+ margin: 0;
+ padding: 1rem 0;
+}
+.TaskList-itemLogLine {
+ padding: 0 1rem;
+}
+.TaskList-itemLogLine:nth-child(even) {
+ background-color: #fafafa;
+}
.Button {
background: #375eab;
border-radius: 0.1875rem;
diff --git a/internal/relui/templates/home.html b/internal/relui/templates/home.html
index 3d87afd..60e17a8 100644
--- a/internal/relui/templates/home.html
+++ b/internal/relui/templates/home.html
@@ -4,33 +4,43 @@
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.Name.String}} - {{index $workflow.ID}}</h3>
- <h4 class="WorkflowList-sectionTitle">Tasks</h4>
- <ul class="TaskList">
- {{$tasks := index $.WorkflowTasks $workflow.ID}}
- {{range $task := $tasks}}
- <li class="TaskList-item">
- <span class="TaskList-itemTitle">{{$task.Name}}</span>
- Finished: {{$task.Finished}}
- Result: {{$task.Result.String}}
- Name: {{$task.Name}}
+ <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 class="WorkflowList-title">
+ {{$workflow.Name.String}} -
+ {{index $workflow.ID}}
+ </h3>
+ <h4 class="WorkflowList-sectionTitle">Tasks</h4>
+ <ul class="TaskList">
+ {{$tasks := index $.WorkflowTasks $workflow.ID}}
+ {{range $task := $tasks}}
+ <li class="TaskList-item">
+ <details class="TaskList-itemDetails">
+ <summary class="TaskList-itemSummary">
+ <span class="TaskList-itemTitle">{{$task.Name}}</span>
+ Finished: {{$task.Finished}} Result: {{$task.Result.String}} Name:
+ {{$task.Name}}
+ </summary>
+ <div class="TaskList-itemLogs">
+ {{range $log := $.Logs $workflow.ID $task.Name}}
+ <div class="TaskList-itemLogLine">
+ {{$log.CreatedAt.Format "2006/01/02 15:04:05"}}
+ {{$log.Body}}
+ </div>
+ {{end}}
+ </div>
+ </details>
+ </li>
+ {{end}}
+ </ul>
</li>
- {{end}}
- <li class="TaskList-item">
- <span class="TaskList-itemTitle">Sample Task</span>
- Status: created
- </li>
- </ul>
- </li>
- {{end}}
- </ul>
-</section>
+ {{end}}
+ </ul>
+ </section>
{{end}}
diff --git a/internal/relui/templates/new_workflow.html b/internal/relui/templates/new_workflow.html
index f05f85f..bdcf547 100644
--- a/internal/relui/templates/new_workflow.html
+++ b/internal/relui/templates/new_workflow.html
@@ -7,31 +7,29 @@
<section class="NewWorkflow">
<h2>New Go Release</h2>
<form action="/workflows/new" method="get">
- <label for="workflow.name">
- Workflow:
- </label>
+ <label for="workflow.name">Workflow:</label>
<select id="workflow.name" name="workflow.name" onchange="this.form.submit()">
<option value="">Select Workflow</option>
- {{range $name, $definition := .Definitions}}
- <option value="{{$name}}" {{if eq $name $.Name}}selected="selected"{{end}}>
- {{$name}}
- </option>
- {{end}}
+ {{range $name, $definition := .Definitions}}
+ <option value="{{$name}}" {{if eq $name $.Name}}selected="selected"{{end}}>
+ {{$name}}
+ </option>
+ {{end}}
</select>
<noscript>
- <input name="workflow.new" type="submit" value="New"/>
+ <input name="workflow.new" type="submit" value="New" />
</noscript>
</form>
{{if .Selected}}
<form action="/workflows/create" method="post">
- <input type="hidden" id="workflow.name" name="workflow.name" value="{{$.Name}}">
+ <input type="hidden" id="workflow.name" name="workflow.name" value="{{$.Name}}" />
{{range $name := .Selected.ParameterNames}}
<div class="NewWorkflow-Parameter">
<label for="workflow.params.{{$name}}">{{$name}}</label>
- <input id="workflow.params.{{$name}}" name="workflow.params.{{$name}}" value=""/>
+ <input id="workflow.params.{{$name}}" name="workflow.params.{{$name}}" value="" />
</div>
{{end}}
- <input name="workflow.create" type="submit" value="Create"/>
+ <input name="workflow.create" type="submit" value="Create" />
</form>
{{end}}
</section>
diff --git a/internal/relui/web.go b/internal/relui/web.go
index 7db891e..584ca29 100644
--- a/internal/relui/web.go
+++ b/internal/relui/web.go
@@ -76,29 +76,27 @@
type homeResponse struct {
Workflows []db.Workflow
WorkflowTasks map[uuid.UUID][]db.Task
+ TaskLogs map[uuid.UUID]map[string][]db.TaskLog
+}
+
+func (h *homeResponse) Logs(workflow uuid.UUID, task string) []db.TaskLog {
+ t := h.TaskLogs[workflow]
+ if t == nil {
+ return nil
+ }
+ return t[task]
}
// homeHandler renders the homepage.
func (s *Server) homeHandler(w http.ResponseWriter, r *http.Request) {
- q := db.New(s.db)
- ws, err := q.Workflows(r.Context())
+ resp, err := s.buildHomeResponse(r.Context())
if err != nil {
log.Printf("homeHandler: %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
- tasks, err := q.Tasks(r.Context())
- if err != nil {
- log.Printf("homeHandler: %v", err)
- http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
- wfTasks := make(map[uuid.UUID][]db.Task, len(ws))
- for _, t := range tasks {
- wfTasks[t.WorkflowID] = append(wfTasks[t.WorkflowID], t)
- }
out := bytes.Buffer{}
- if err := homeTmpl.Execute(&out, homeResponse{Workflows: ws, WorkflowTasks: wfTasks}); err != nil {
+ if err := homeTmpl.Execute(&out, resp); err != nil {
log.Printf("homeHandler: %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@@ -106,6 +104,34 @@
io.Copy(w, &out)
}
+func (s *Server) buildHomeResponse(ctx context.Context) (*homeResponse, error) {
+ q := db.New(s.db)
+ ws, err := q.Workflows(ctx)
+ if err != nil {
+ return nil, err
+ }
+ tasks, err := q.Tasks(ctx)
+ if err != nil {
+ return nil, err
+ }
+ wfTasks := make(map[uuid.UUID][]db.Task, len(ws))
+ for _, t := range tasks {
+ wfTasks[t.WorkflowID] = append(wfTasks[t.WorkflowID], t)
+ }
+ tlogs, err := q.TaskLogs(ctx)
+ if err != nil {
+ return nil, err
+ }
+ wftlogs := make(map[uuid.UUID]map[string][]db.TaskLog)
+ for _, l := range tlogs {
+ if wftlogs[l.WorkflowID] == nil {
+ wftlogs[l.WorkflowID] = make(map[string][]db.TaskLog)
+ }
+ wftlogs[l.WorkflowID][l.TaskName] = append(wftlogs[l.WorkflowID][l.TaskName], l)
+ }
+ return &homeResponse{Workflows: ws, WorkflowTasks: wfTasks, TaskLogs: wftlogs}, nil
+}
+
type newWorkflowResponse struct {
Definitions map[string]*workflow.Definition
Name string
@@ -181,55 +207,3 @@
}(wf, s.db)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
-
-// listener implements workflow.Listener for recording workflow state.
-type listener struct {
- db *pgxpool.Pool
-}
-
-// TaskStateChanged is called whenever a task is updated by the
-// workflow. The workflow.TaskState is persisted as a db.Task,
-// creating or updating a row as necessary.
-func (l *listener) TaskStateChanged(workflowID uuid.UUID, taskID string, state *workflow.TaskState) error {
- log.Printf("TaskStateChanged(%q, %q, %v)", workflowID, taskID, state)
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- result, err := json.Marshal(state.Result)
- if err != nil {
- return err
- }
- err = l.db.BeginFunc(ctx, func(tx pgx.Tx) error {
- q := db.New(tx)
- updated := time.Now()
- _, err := q.UpsertTask(ctx, db.UpsertTaskParams{
- WorkflowID: workflowID,
- Name: taskID,
- Finished: state.Finished,
- Result: sql.NullString{String: string(result), Valid: len(result) > 0},
- Error: sql.NullString{},
- CreatedAt: updated,
- UpdatedAt: updated,
- })
- if err != nil {
- return err
- }
- return nil
- })
- if err != nil {
- log.Printf("TaskStateChanged(%q, %q, %v) = %v", workflowID, taskID, state, err)
- }
- return err
-}
-
-func (l *listener) Logger(workflowID uuid.UUID, taskID string) workflow.Logger {
- return &stdoutLogger{WorkflowID: workflowID, TaskID: taskID}
-}
-
-type stdoutLogger struct {
- WorkflowID uuid.UUID
- TaskID string
-}
-
-func (l *stdoutLogger) Printf(format string, v ...interface{}) {
- log.Printf("%q(%q): %v", l.WorkflowID, l.TaskID, fmt.Sprintf(format, v...))
-}
diff --git a/internal/relui/web_test.go b/internal/relui/web_test.go
index b3bd8d8..2a12386 100644
--- a/internal/relui/web_test.go
+++ b/internal/relui/web_test.go
@@ -194,7 +194,8 @@
desc: "successful creation",
params: url.Values{
"workflow.name": []string{"echo"},
- "workflow.params.greeting": []string{"abc"},
+ "workflow.params.greeting": []string{"hello"},
+ "workflow.params.farewell": []string{"bye"},
},
wantCode: http.StatusSeeOther,
wantHeaders: map[string]string{
@@ -203,7 +204,7 @@
wantWorkflows: []db.Workflow{
{
ID: uuid.New(), // SameUUIDVariant
- Params: nullString(`{"greeting": "abc"}`),
+ Params: nullString(`{"farewell": "bye", "greeting": "hello"}`),
Name: nullString(`Echo`),
CreatedAt: time.Now(), // cmpopts.EquateApproxTime
UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
@@ -211,8 +212,13 @@
},
wantTasks: []db.Task{
{
- Name: "echo",
- Finished: false,
+ Name: "greeting",
+ Error: sql.NullString{},
+ CreatedAt: time.Now(), // cmpopts.EquateApproxTime
+ UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
+ },
+ {
+ Name: "farewell",
Error: sql.NullString{},
CreatedAt: time.Now(), // cmpopts.EquateApproxTime
UpdatedAt: time.Now(), // cmpopts.EquateApproxTime
@@ -262,8 +268,8 @@
cancel()
}
})
- if len(c.wantTasks) > 0 {
- c.wantTasks[0].WorkflowID = wfs[0].ID
+ for i := range c.wantTasks {
+ c.wantTasks[i].WorkflowID = wfs[0].ID
}
if diff := cmp.Diff(c.wantTasks, tasks, cmpopts.EquateApproxTime(time.Minute), cmpopts.IgnoreFields(db.Task{}, "Finished", "Result")); diff != "" {
t.Errorf("q.TasksForWorkflow(_, %q) mismatch (-want +got):\n%s", wfs[0].ID, diff)
diff --git a/internal/relui/workflows.go b/internal/relui/workflows.go
index b5b17ae..4677397 100644
--- a/internal/relui/workflows.go
+++ b/internal/relui/workflows.go
@@ -5,8 +5,6 @@
package relui
import (
- "context"
-
"golang.org/x/build/internal/workflow"
)
@@ -20,10 +18,13 @@
// development.
func newEchoWorkflow() *workflow.Definition {
wd := workflow.New()
- wd.Output("greeting", wd.Task("echo", echo, wd.Parameter("greeting")))
+ greeting := wd.Task("greeting", echo, wd.Parameter("greeting"))
+ wd.Output("greeting", greeting)
+ wd.Output("farewell", wd.Task("farewell", echo, wd.Parameter("farewell")))
return wd
}
-func echo(_ context.Context, arg string) (string, error) {
+func echo(ctx *workflow.TaskContext, arg string) (string, error) {
+ ctx.Printf("echo(%v, %q)", ctx, arg)
return arg, nil
}