internal/relui: add delete schedules button

Add a button to permanently delete a workflow schedule.

For golang/go#54476

Change-Id: I14d5ba66e7ce609dfea97af9ecd2aa6173d03524
Reviewed-on: https://go-review.googlesource.com/c/build/+/432405
Run-TryBot: Jenny Rakoczy <jenny@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
Auto-Submit: Jenny Rakoczy <jenny@golang.org>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/internal/relui/db/workflows.sql.go b/internal/relui/db/workflows.sql.go
index 93033b2..bc6c26a 100644
--- a/internal/relui/db/workflows.sql.go
+++ b/internal/relui/db/workflows.sql.go
@@ -47,6 +47,33 @@
 	return i, err
 }
 
+const clearWorkflowSchedule = `-- name: ClearWorkflowSchedule :many
+UPDATE workflows
+SET schedule_id = NULL
+WHERE schedule_id = $1::int
+RETURNING id
+`
+
+func (q *Queries) ClearWorkflowSchedule(ctx context.Context, dollar_1 int32) ([]uuid.UUID, error) {
+	rows, err := q.db.Query(ctx, clearWorkflowSchedule, dollar_1)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var items []uuid.UUID
+	for rows.Next() {
+		var id uuid.UUID
+		if err := rows.Scan(&id); err != nil {
+			return nil, err
+		}
+		items = append(items, id)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return items, nil
+}
+
 const createSchedule = `-- name: CreateSchedule :one
 INSERT INTO schedules (workflow_name, workflow_params, spec, once, interval_minutes, created_at, updated_at)
 VALUES ($1, $2, $3, $4, $5, $6, $7)
diff --git a/internal/relui/queries/workflows.sql b/internal/relui/queries/workflows.sql
index 320734b..0c97f8d 100644
--- a/internal/relui/queries/workflows.sql
+++ b/internal/relui/queries/workflows.sql
@@ -193,6 +193,12 @@
 WHERE id = $1
 RETURNING *;
 
+-- name: ClearWorkflowSchedule :many
+UPDATE workflows
+SET schedule_id = NULL
+WHERE schedule_id = $1::int
+RETURNING id;
+
 -- name: SchedulesLastRun :many
 WITH last_scheduled_run AS (
     SELECT DISTINCT ON (schedule_id) schedule_id, id, created_at, workflows.error, finished
diff --git a/internal/relui/schedule.go b/internal/relui/schedule.go
index cf114c9..3dc449f 100644
--- a/internal/relui/schedule.go
+++ b/internal/relui/schedule.go
@@ -8,6 +8,7 @@
 	"context"
 	"database/sql"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"log"
 	"strings"
@@ -223,6 +224,33 @@
 	return ret
 }
 
+var ErrScheduleNotFound = errors.New("schedule not found")
+
+// Delete removes a schedule from the scheduler, preventing subsequent
+// runs, and deletes the schedule from the database.
+//
+// Jobs in progress are not interrupted, but will be prevented from
+// starting again.
+func (s *Scheduler) Delete(ctx context.Context, id int) error {
+	entries := s.Entries()
+	i := slices.IndexFunc(entries, func(e ScheduleEntry) bool { return int(e.WorkflowJob().Schedule.ID) == id })
+	if i == -1 {
+		return ErrScheduleNotFound
+	}
+	entry := entries[i]
+	s.cron.Remove(entry.ID)
+	return s.db.BeginFunc(ctx, func(tx pgx.Tx) error {
+		q := db.New(tx)
+		if _, err := q.ClearWorkflowSchedule(ctx, int32(id)); err != nil {
+			return err
+		}
+		if _, err := q.DeleteSchedule(ctx, int32(id)); err != nil {
+			return err
+		}
+		return nil
+	})
+}
+
 type ScheduleEntry struct {
 	cron.Entry
 	LastRun db.SchedulesLastRunRow
diff --git a/internal/relui/schedule_test.go b/internal/relui/schedule_test.go
index 6afc697..e6695ad 100644
--- a/internal/relui/schedule_test.go
+++ b/internal/relui/schedule_test.go
@@ -267,3 +267,81 @@
 		})
 	}
 }
+
+func TestScheduleDelete(t *testing.T) {
+	now := time.Now()
+	cases := []struct {
+		desc          string
+		sched         Schedule
+		workflowName  string
+		params        map[string]any
+		wantErr       bool
+		wantEntries   []ScheduleEntry
+		want          []db.Schedule
+		wantWorkflows []db.Workflow
+	}{
+		{
+			desc:         "success",
+			sched:        Schedule{Once: now.AddDate(1, 0, 0), Type: ScheduleOnce},
+			workflowName: "echo",
+			params:       map[string]any{"greeting": "hello", "farewell": "bye"},
+			wantEntries:  []ScheduleEntry{},
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.desc, func(t *testing.T) {
+			ctx, cancel := context.WithCancel(context.Background())
+			defer cancel()
+			p := testDB(ctx, t)
+			q := db.New(p)
+			s := NewScheduler(p, NewWorker(NewDefinitionHolder(), p, &PGListener{p}))
+			row, err := s.Create(ctx, c.sched, c.workflowName, c.params)
+			if err != nil {
+				t.Fatalf("s.Create(_, %v, %q, %v) = %v, %v, wanted no error", c.sched, c.workflowName, c.params, row, err)
+			}
+			// simulate a single run
+			wfid, err := s.w.StartWorkflow(ctx, c.workflowName, c.params, int(row.ID))
+			if err != nil {
+				t.Fatalf("s.w.StartWorkflow(_, %q, %v, %d) = %q, %v, wanted no error", c.workflowName, c.params, row.ID, wfid.String(), err)
+			}
+
+			err = s.Delete(ctx, int(row.ID))
+			if (err != nil) != c.wantErr {
+				t.Fatalf("s.Delete(%d) = %v, wantErr: %t", row.ID, err, c.wantErr)
+			}
+
+			entries := s.Entries()
+			diffOpts := []cmp.Option{
+				cmpopts.EquateApproxTime(time.Minute),
+				cmpopts.IgnoreFields(db.Schedule{}, "ID"),
+				cmpopts.IgnoreUnexported(RunOnce{}, WorkflowSchedule{}, time.Location{}),
+				cmpopts.IgnoreFields(ScheduleEntry{}, "ID", "LastRun.ID", "WrappedJob"),
+			}
+			if c.sched.Type == ScheduleCron {
+				diffOpts = append(diffOpts, cmpopts.IgnoreFields(ScheduleEntry{}, "Next"))
+			}
+			if diff := cmp.Diff(c.wantEntries, entries, diffOpts...); diff != "" {
+				t.Errorf("s.Entries() mismatch (-want +got):\n%s", diff)
+			}
+			got, err := q.Schedules(ctx)
+			if err != nil {
+				t.Fatalf("q.Schedules() = %v, %v, wanted no error", got, err)
+			}
+			if diff := cmp.Diff(c.want, got, diffOpts...); diff != "" {
+				t.Errorf("q.Schedules() mismatch (-want +got):\n%s", diff)
+			}
+			wfs, err := q.Workflows(ctx)
+			if err != nil {
+				t.Fatalf("q.Workflows() = %v, %v, wanted no error", wfs, err)
+			}
+			if len(wfs) != 1 {
+				t.Errorf("len(q.Workflows()) = %d, wanted %d", len(wfs), 1)
+			}
+			for _, w := range wfs {
+				if w.ScheduleID.Int32 == row.ID {
+					t.Errorf("w.ScheduleID = %d, wanted != %d", w.ScheduleID.Int32, row.ID)
+				}
+			}
+		})
+	}
+}
diff --git a/internal/relui/static/styles.css b/internal/relui/static/styles.css
index f3ee6c9..f06d05b 100644
--- a/internal/relui/static/styles.css
+++ b/internal/relui/static/styles.css
@@ -484,6 +484,9 @@
   text-overflow: ellipsis;
   white-space: nowrap;
 }
+.WorkflowList-itemActions {
+  width: 12.8125rem;
+}
 .WorkflowList-itemStateHeader,
 .WorkflowList-itemState {
   width: 2.5rem;
diff --git a/internal/relui/templates/home.html b/internal/relui/templates/home.html
index 9ce514c..799960e 100644
--- a/internal/relui/templates/home.html
+++ b/internal/relui/templates/home.html
@@ -28,6 +28,7 @@
         <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>
+        <th class="WorkflowList-itemHeaderCol WorkflowList-itemActions">Actions</th>
       </tr>
       </thead>
       <tbody>
@@ -78,6 +79,18 @@
               {{$schedule.Prev.UTC.Format "Mon, 02 Jan 2006 15:04:05 MST"}}
             {{end}}
           </td>
+          <td class="WorkflowList-itemAction">
+            <div class="WorkflowList-deleteSchedule">
+              <form action="{{baseLink (printf "/schedules/%d/delete" $schedule.WorkflowJob.Schedule.ID)}}" method="post">
+                <input type="hidden" name="schedule.id" value="{{$schedule.WorkflowJob.Schedule.ID}}" />
+                <input class="Button Button--small"
+                       name="schedule.delete"
+                       type="submit"
+                       value="Delete"
+                       onclick="return this.form.reportValidity() && confirm('This will cancel and permanently delete the schedule.\n\nReady to proceed?')" />
+              </form>
+            </div>
+          </td>
         </tr>
       {{else}}
         <tr>
diff --git a/internal/relui/web.go b/internal/relui/web.go
index d556efa..fcc9db1 100644
--- a/internal/relui/web.go
+++ b/internal/relui/web.go
@@ -18,6 +18,7 @@
 	"net/url"
 	"path"
 	"reflect"
+	"strconv"
 	"strings"
 	"time"
 
@@ -90,6 +91,7 @@
 	s.m.POST("/workflows/:id/stop", s.stopWorkflowHandler)
 	s.m.POST("/workflows/:id/tasks/:name/retry", s.retryTaskHandler)
 	s.m.POST("/workflows/:id/tasks/:name/approve", s.approveTaskHandler)
+	s.m.POST("/schedules/:id/delete", s.deleteScheduleHandler)
 	s.m.Handler(http.MethodGet, "/metrics", ms)
 	s.m.Handler(http.MethodGet, "/new_workflow", http.HandlerFunc(s.newWorkflowHandler))
 	s.m.Handler(http.MethodPost, "/workflows", http.HandlerFunc(s.createWorkflowHandler))
@@ -465,6 +467,26 @@
 	http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther)
 }
 
+func (s *Server) deleteScheduleHandler(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
+	id, err := strconv.Atoi(params.ByName("id"))
+	if err != nil {
+		log.Printf("deleteScheduleHandler(_, _, %v) strconv.Atoi(%q) = %d, %v", params, params.ByName("id"), id, err)
+		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+		return
+	}
+	err = s.scheduler.Delete(r.Context(), id)
+	if err == ErrScheduleNotFound {
+		http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+		return
+	} else if err != nil {
+		log.Printf("deleteScheduleHandler(_, _, %v) s.scheduler.Delete(_, %d) = %v", params, id, err)
+		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+		return
+	}
+
+	http.Redirect(w, r, s.BaseLink("/"), http.StatusSeeOther)
+}
+
 // resultDetail contains unmarshalled results from a workflow task, or
 // workflow output. Only one field is expected to be populated.
 //