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)
}