internal/relui: show schedules on workflow lists

Add a small table to show scheduled workflows, including the last and
next time they will run.

For golang/go#54476

Change-Id: I82a7e34f58ff77eb59df330f52564aff6b6c581f
Reviewed-on: https://go-review.googlesource.com/c/build/+/432404
Run-TryBot: Jenny Rakoczy <jenny@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
Auto-Submit: Jenny Rakoczy <jenny@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
diff --git a/cmd/relui/sqlc.yaml b/cmd/relui/sqlc.yaml
index 7ee3a77..5975b37 100644
--- a/cmd/relui/sqlc.yaml
+++ b/cmd/relui/sqlc.yaml
@@ -23,3 +23,6 @@
           - column: "schedules.once"
             go_type:
               type: "time.Time"
+          - go_type: "github.com/google/uuid.UUID"
+            db_type: "uuid"
+            nullable: true
diff --git a/internal/relui/db/workflows.sql.go b/internal/relui/db/workflows.sql.go
index de9029f..93033b2 100644
--- a/internal/relui/db/workflows.sql.go
+++ b/internal/relui/db/workflows.sql.go
@@ -328,6 +328,55 @@
 	return items, nil
 }
 
+const schedulesLastRun = `-- name: SchedulesLastRun :many
+WITH last_scheduled_run AS (
+    SELECT DISTINCT ON (schedule_id) schedule_id, id, created_at, workflows.error, finished
+    FROM workflows
+    ORDER BY schedule_id, workflows.created_at DESC
+)
+SELECT schedules.id,
+       last_scheduled_run.id AS workflow_id,
+       last_scheduled_run.created_at AS workflow_created_at,
+       last_scheduled_run.error AS workflow_error,
+       last_scheduled_run.finished AS workflow_finished
+FROM schedules
+LEFT OUTER JOIN last_scheduled_run ON last_scheduled_run.schedule_id = schedules.id
+`
+
+type SchedulesLastRunRow struct {
+	ID                int32
+	WorkflowID        uuid.UUID
+	WorkflowCreatedAt sql.NullTime
+	WorkflowError     sql.NullString
+	WorkflowFinished  sql.NullBool
+}
+
+func (q *Queries) SchedulesLastRun(ctx context.Context) ([]SchedulesLastRunRow, error) {
+	rows, err := q.db.Query(ctx, schedulesLastRun)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []SchedulesLastRunRow
+	for rows.Next() {
+		var i SchedulesLastRunRow
+		if err := rows.Scan(
+			&i.ID,
+			&i.WorkflowID,
+			&i.WorkflowCreatedAt,
+			&i.WorkflowError,
+			&i.WorkflowFinished,
+		); err != nil {
+			return nil, err
+		}
+		items = append(items, i)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
 const task = `-- name: Task :one
 SELECT tasks.workflow_id, tasks.name, tasks.finished, tasks.result, tasks.error, tasks.created_at, tasks.updated_at, tasks.approved_at, tasks.ready_for_approval, tasks.started, tasks.retry_count
 FROM tasks
@@ -834,19 +883,19 @@
 }
 
 const workflowNames = `-- name: WorkflowNames :many
-SELECT DISTINCT name
+SELECT DISTINCT name::text
 FROM workflows
 `
 
-func (q *Queries) WorkflowNames(ctx context.Context) ([]sql.NullString, error) {
+func (q *Queries) WorkflowNames(ctx context.Context) ([]string, error) {
 	rows, err := q.db.Query(ctx, workflowNames)
 	if err != nil {
 		return nil, err
 	}
 	defer rows.Close()
-	var items []sql.NullString
+	var items []string
 	for rows.Next() {
-		var name sql.NullString
+		var name string
 		if err := rows.Scan(&name); err != nil {
 			return nil, err
 		}
diff --git a/internal/relui/queries/workflows.sql b/internal/relui/queries/workflows.sql
index f2afdb9..320734b 100644
--- a/internal/relui/queries/workflows.sql
+++ b/internal/relui/queries/workflows.sql
@@ -20,7 +20,7 @@
 ORDER BY created_at DESC;
 
 -- name: WorkflowNames :many
-SELECT DISTINCT name
+SELECT DISTINCT name::text
 FROM workflows;
 
 -- name: Workflow :one
@@ -192,3 +192,17 @@
 FROM schedules
 WHERE id = $1
 RETURNING *;
+
+-- name: SchedulesLastRun :many
+WITH last_scheduled_run AS (
+    SELECT DISTINCT ON (schedule_id) schedule_id, id, created_at, workflows.error, finished
+    FROM workflows
+    ORDER BY schedule_id, workflows.created_at DESC
+)
+SELECT schedules.id,
+       last_scheduled_run.id AS workflow_id,
+       last_scheduled_run.created_at AS workflow_created_at,
+       last_scheduled_run.error AS workflow_error,
+       last_scheduled_run.finished AS workflow_finished
+FROM schedules
+LEFT OUTER JOIN last_scheduled_run ON last_scheduled_run.schedule_id = schedules.id;
diff --git a/internal/relui/schedule.go b/internal/relui/schedule.go
index ee2eb86..cf114c9 100644
--- a/internal/relui/schedule.go
+++ b/internal/relui/schedule.go
@@ -16,6 +16,7 @@
 	"github.com/jackc/pgx/v4"
 	"github.com/robfig/cron/v3"
 	"golang.org/x/build/internal/relui/db"
+	"golang.org/x/exp/slices"
 )
 
 // ScheduleType determines whether a workflow runs immediately or on
@@ -194,16 +195,40 @@
 }
 
 // Entries returns a slice of active jobs.
-func (s *Scheduler) Entries() []ScheduleEntry {
+//
+// Entries are filtered by workflowNames. An empty slice returns
+// all entries.
+func (s *Scheduler) Entries(workflowNames ...string) []ScheduleEntry {
+	q := db.New(s.db)
+	rows, err := q.SchedulesLastRun(context.Background())
+	if err != nil {
+		log.Printf("q.SchedulesLastRun() = _, %q, wanted no error", err)
+	}
+	rowMap := make(map[int32]db.SchedulesLastRunRow)
+	for _, row := range rows {
+		rowMap[row.ID] = row
+	}
 	entries := s.cron.Entries()
-	ret := make([]ScheduleEntry, len(entries))
-	for i, e := range s.cron.Entries() {
-		ret[i] = (ScheduleEntry)(e)
+	ret := make([]ScheduleEntry, 0, len(entries))
+	for _, e := range s.cron.Entries() {
+		entry := ScheduleEntry{Entry: e}
+		if len(workflowNames) != 0 && !slices.Contains(workflowNames, entry.WorkflowJob().Schedule.WorkflowName) {
+			continue
+		}
+		if row, ok := rowMap[entry.WorkflowJob().Schedule.ID]; ok {
+			entry.LastRun = row
+		}
+		ret = append(ret, entry)
 	}
 	return ret
 }
 
-type ScheduleEntry cron.Entry
+type ScheduleEntry struct {
+	cron.Entry
+	LastRun db.SchedulesLastRunRow
+}
+
+// type ScheduleEntry cron.Entry
 
 // WorkflowJob returns a *WorkflowSchedule for the ScheduleEntry.
 func (s *ScheduleEntry) WorkflowJob() *WorkflowSchedule {
diff --git a/internal/relui/schedule_test.go b/internal/relui/schedule_test.go
index 789c0cf..6afc697 100644
--- a/internal/relui/schedule_test.go
+++ b/internal/relui/schedule_test.go
@@ -52,7 +52,7 @@
 				UpdatedAt: now,
 			},
 			wantEntries: []ScheduleEntry{
-				{
+				{Entry: cron.Entry{
 					Schedule: &RunOnce{next: now.UTC().AddDate(1, 0, 0)},
 					Next:     now.UTC().AddDate(1, 0, 0),
 					Job: &WorkflowSchedule{
@@ -68,7 +68,7 @@
 						},
 						Params: map[string]any{"greeting": "hello", "farewell": "bye"},
 					},
-				},
+				}},
 			},
 		},
 		{
@@ -87,7 +87,7 @@
 				UpdatedAt: now,
 			},
 			wantEntries: []ScheduleEntry{
-				{
+				{Entry: cron.Entry{
 					Schedule: mustParseSpec(t, "* * * * *"),
 					Next:     now.Add(time.Minute),
 					Job: &WorkflowSchedule{
@@ -103,7 +103,7 @@
 						},
 						Params: map[string]any{"greeting": "hello", "farewell": "bye"},
 					},
-				},
+				}},
 			},
 		},
 		{
@@ -134,7 +134,7 @@
 				cmpopts.EquateApproxTime(time.Minute),
 				cmpopts.IgnoreFields(db.Schedule{}, "ID"),
 				cmpopts.IgnoreUnexported(RunOnce{}, WorkflowSchedule{}, time.Location{}),
-				cmpopts.IgnoreFields(ScheduleEntry{}, "ID", "WrappedJob"),
+				cmpopts.IgnoreFields(ScheduleEntry{}, "ID", "LastRun.ID", "WrappedJob"),
 			}
 			if diff := cmp.Diff(c.wantEntries, got, diffOpts...); diff != "" {
 				t.Fatalf("s.Entries() mismatch (-want +got):\n%s", diff)
@@ -167,7 +167,7 @@
 				},
 			},
 			want: []ScheduleEntry{
-				{
+				{Entry: cron.Entry{
 					Schedule: &RunOnce{next: now.UTC().AddDate(1, 0, 0)},
 					Next:     now.UTC().AddDate(1, 0, 0),
 					Job: &WorkflowSchedule{
@@ -183,7 +183,7 @@
 						},
 						Params: map[string]any{"greeting": "hello", "farewell": "bye"},
 					},
-				},
+				}},
 			},
 		},
 		{
@@ -201,7 +201,7 @@
 				},
 			},
 			want: []ScheduleEntry{
-				{
+				{Entry: cron.Entry{
 					Schedule: mustParseSpec(t, "* * * * *"),
 					Next:     now.Add(time.Minute),
 					Job: &WorkflowSchedule{
@@ -217,7 +217,7 @@
 						},
 						Params: map[string]any{"greeting": "hello", "farewell": "bye"},
 					},
-				},
+				}},
 			},
 		},
 		{
@@ -259,7 +259,7 @@
 				cmpopts.EquateApproxTime(time.Minute),
 				cmpopts.IgnoreFields(db.Schedule{}, "ID"),
 				cmpopts.IgnoreUnexported(RunOnce{}, WorkflowSchedule{}, time.Location{}),
-				cmpopts.IgnoreFields(ScheduleEntry{}, "ID", "WrappedJob"),
+				cmpopts.IgnoreFields(ScheduleEntry{}, "ID", "LastRun.ID", "WrappedJob"),
 			}
 			if diff := cmp.Diff(c.want, got, diffOpts...); diff != "" {
 				t.Fatalf("s.Entries() mismatch (-want +got):\n%s", diff)
diff --git a/internal/relui/templates/home.html b/internal/relui/templates/home.html
index 70c8fc9..9ce514c 100644
--- a/internal/relui/templates/home.html
+++ b/internal/relui/templates/home.html
@@ -20,6 +20,71 @@
     </div>
     <h2>Active Workflows</h2>
     {{template "workflow_list" .ActiveWorkflows}}
+    <h2>Scheduled Workflows</h2>
+    <table class="WorkflowList">
+      <thead>
+      <tr class="WorkflowList-itemHeader">
+        <th class="WorkflowList-itemHeaderCol WorkflowList-itemStateHeader">State</th>
+        <th class="WorkflowList-itemHeaderCol WorkflowList-itemName">Name</th>
+        <th class="WorkflowList-itemHeaderCol WorkflowList-itemCreated">Next Run</th>
+        <th class="WorkflowList-itemHeaderCol WorkflowList-itemUpdated">Last Run</th>
+      </tr>
+      </thead>
+      <tbody>
+
+      </tbody>
+      {{- /* gotype: golang.org/x/build/internal/relui.ScheduleEntry */ -}}
+      {{range $schedule := .Schedules}}
+        <tr class="WorkflowList-item">
+          <td class="WorkflowList-itemState">
+              {{if ne $schedule.LastRun.WorkflowError.String ""}}
+                <img
+                        class="WorkflowList-itemStateIcon"
+                        alt="{{$schedule.LastRun.WorkflowError.String}}"
+                        src="{{baseLink "/static/images/error_red_24dp.svg"}}" />
+              {{else if $schedule.LastRun.WorkflowFinished.Bool}}
+                <img
+                        class="WorkflowList-itemStateIcon"
+                        alt="finished"
+                        src="{{baseLink "/static/images/check_circle_green_24dp.svg"}}" />
+              {{else if not $schedule.LastRun.WorkflowCreatedAt.Time.IsZero }}
+                <img
+                        class="WorkflowList-itemStateIcon"
+                        alt="started"
+                        src="{{baseLink "/static/images/pending_yellow_24dp.svg"}}" />
+              {{else}}
+                <img
+                        class="WorkflowList-itemStateIcon"
+                        alt="pending"
+                        src="{{baseLink "/static/images/pending_grey_24dp.svg"}}" />
+              {{end}}
+          </td>
+          <td class="WorkflowList-itemName">
+            {{with $schedule.WorkflowJob}}
+              {{.Schedule.WorkflowName}}
+            {{end}}
+          </td>
+          <td class="WorkflowList-itemCreated">
+            {{if not $schedule.Next.IsZero}}
+              {{$schedule.Next.UTC.Format "Mon, 02 Jan 2006 15:04:05 MST"}}
+            {{end}}
+          </td>
+          <td class="WorkflowList-itemUpdated">
+            {{if not $schedule.LastRun.WorkflowCreatedAt.Time.IsZero }}
+              <a href="{{baseLink "/workflows/" $schedule.LastRun.WorkflowID.String}}">
+                {{$schedule.LastRun.WorkflowCreatedAt.Time.UTC.Format "Mon, 02 Jan 2006 15:04:05 MST"}}
+              </a>
+            {{else if not $schedule.Prev.IsZero}}
+              {{$schedule.Prev.UTC.Format "Mon, 02 Jan 2006 15:04:05 MST"}}
+            {{end}}
+          </td>
+        </tr>
+      {{else}}
+        <tr>
+          <td>None</td>
+        </tr>
+      {{end}}
+    </table>
     <h2>Completed Workflows</h2>
     {{template "workflow_list" .InactiveWorkflows}}
   </section>
diff --git a/internal/relui/web.go b/internal/relui/web.go
index 5a240d6..d556efa 100644
--- a/internal/relui/web.go
+++ b/internal/relui/web.go
@@ -170,6 +170,7 @@
 	SiteHeader        SiteHeader
 	ActiveWorkflows   []db.Workflow
 	InactiveWorkflows []db.Workflow
+	Schedules         []ScheduleEntry
 }
 
 func workflowParams(wf db.Workflow) (map[string]string, error) {
@@ -197,10 +198,10 @@
 	}
 	var others []string
 	for _, name := range names {
-		if s.w.dh.Definition(name.String) != nil {
+		if s.w.dh.Definition(name) != nil {
 			continue
 		}
-		others = append(others, name.String)
+		others = append(others, name)
 	}
 
 	name := r.URL.Query().Get("name")
@@ -211,11 +212,14 @@
 	case "all", "All", "":
 		ws, err = q.Workflows(r.Context())
 		hr.SiteHeader.NameParam = "All Workflows"
+		hr.Schedules = s.scheduler.Entries()
 	case "others", "Others":
 		ws, err = q.WorkflowsByNames(r.Context(), others)
 		hr.SiteHeader.NameParam = "Others"
+		hr.Schedules = s.scheduler.Entries(others...)
 	default:
 		ws, err = q.WorkflowsByName(r.Context(), sql.NullString{String: name, Valid: true})
+		hr.Schedules = s.scheduler.Entries(name)
 	}
 	if err != nil {
 		log.Printf("homeHandler: %v", err)
diff --git a/internal/relui/web_test.go b/internal/relui/web_test.go
index 1382674..70b4aac 100644
--- a/internal/relui/web_test.go
+++ b/internal/relui/web_test.go
@@ -105,7 +105,7 @@
 	p := testDB(ctx, t)
 
 	q := db.New(p)
-	wf := db.CreateWorkflowParams{ID: uuid.New()}
+	wf := db.CreateWorkflowParams{ID: uuid.New(), Name: nullString("test workflow")}
 	if _, err := q.CreateWorkflow(ctx, wf); err != nil {
 		t.Fatalf("CreateWorkflow(_, %v) = _, %v, wanted no error", wf, err)
 	}