internal/gaby: action log page: controls
Set up a page to display the action log.
This CL contains the controls that select a time interval.
For golang/oscar#9.
Change-Id: I1bd38ac42ba42631604fb07e788861da21df2735
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/616056
Reviewed-by: Tatiana Bradley <tatianabradley@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/gaby/actionlog.go b/internal/gaby/actionlog.go
new file mode 100644
index 0000000..7e8b78a
--- /dev/null
+++ b/internal/gaby/actionlog.go
@@ -0,0 +1,183 @@
+// Copyright 2024 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 (
+ "bytes"
+ "embed"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/google/safehtml/template"
+ "golang.org/x/oscar/internal/actions"
+)
+
+// actionLogPage is the data for the action log HTML template.
+type actionLogPage struct {
+ Start, End endpoint
+ StartTime, EndTime string // formatted times that the endpoints describe
+ Entries []*actions.Entry
+}
+
+// An endpoint holds the values for a UI component for selecting a point in time.
+type endpoint struct {
+ Radio string // "fixed", "dur" or "date"
+ Date string // date and time, see below for format
+ DurNum string // integer
+ DurUnit string // "hours", "minutes", etc.
+}
+
+func (g *Gaby) handleActionLog(w http.ResponseWriter, r *http.Request) {
+ data, status, err := g.doActionLog(r)
+ if err != nil {
+ http.Error(w, err.Error(), status)
+ } else {
+ _, _ = w.Write(data)
+ }
+}
+
+func (g *Gaby) doActionLog(r *http.Request) (content []byte, status int, err error) {
+ var page actionLogPage
+
+ // Fill in the endpoint values from the form on the page.
+ page.Start.formValues(r, "start")
+ page.End.formValues(r, "end")
+
+ startTime, endTime, err := times(page.Start, page.End, time.Now())
+ if err != nil {
+ return nil, http.StatusBadRequest, err
+ }
+
+ // Display a table heading, but only if something was set.
+ if r.FormValue("start") != "" {
+ if startTime.IsZero() {
+ page.StartTime = "the beginning of time"
+ } else {
+ page.StartTime = startTime.Format(time.DateTime)
+ }
+ page.EndTime = endTime.Format(time.DateTime)
+ }
+
+ // Set defaults: from 1 hour ago to now.
+ if page.Start.Radio == "" {
+ page.Start.Radio = "dur"
+ page.Start.DurNum = "1"
+ page.Start.DurUnit = "hours"
+ }
+ if page.End.Radio == "" {
+ page.End.Radio = "fixed"
+ }
+
+ var buf bytes.Buffer
+ if err := actionLogPageTmpl.Execute(&buf, page); err != nil {
+ return nil, http.StatusInternalServerError, err
+ }
+ return buf.Bytes(), http.StatusOK, nil
+}
+
+// formValues populates an endpoint from the values in the form.
+func (e *endpoint) formValues(r *http.Request, prefix string) {
+ e.Radio = r.FormValue(prefix)
+ e.Date = r.FormValue(prefix + "-date")
+ e.DurNum = r.FormValue(prefix + "-dur-num")
+ e.DurUnit = r.FormValue(prefix + "-dur-unit")
+}
+
+// times computes the start and end times from the endpoint controls.
+func times(start, end endpoint, now time.Time) (startTime, endTime time.Time, err error) {
+ var ztime time.Time
+ st, sd, err1 := start.timeOrDuration(now)
+ et, ed, err2 := end.timeOrDuration(now)
+ if err := errors.Join(err1, err2); err != nil {
+ return ztime, ztime, err
+ }
+ if sd != 0 && ed != 0 {
+ return ztime, ztime, errors.New("both endpoints can't be durations")
+ }
+ // TODO(jba): times should be local to the user, not the Gaby server.
+
+ // The "fixed" choice always returns a zero time, but for the end endpoint is should be now.
+ if et.IsZero() && ed == 0 {
+ et = now
+ }
+
+ // Add a duration to a time.
+ if sd != 0 {
+ st = et.Add(-sd)
+ }
+ if ed != 0 {
+ et = st.Add(ed)
+ }
+ if et.Before(st) {
+ return ztime, ztime, errors.New("end time before start time")
+ }
+ return st, et, nil
+}
+
+// timeOrDuration returns the time or duration described by the endpoint's controls.
+// If the controls aren't set, it returns the zero time.
+func (e *endpoint) timeOrDuration(now time.Time) (time.Time, time.Duration, error) {
+ var ztime time.Time
+ switch e.Radio {
+ case "", "fixed":
+ // A fixed time: start at the beginning of time, end at now.
+ // That choice is left to the caller.
+ return ztime, 0, nil
+ case "date":
+ // Format described in
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Date_and_time_formats#local_date_and_time_strings
+ // TODO(jba): times should be local to the user, not the Gaby server.
+ t, err := time.ParseInLocation("2006-01-02T03:04", e.Date, time.Local)
+ if err != nil {
+ return ztime, 0, err
+ }
+ return t, 0, nil
+ case "dur":
+ // A duration after a start time, or before an end time.
+ if e.DurNum == "" {
+ return ztime, 0, errors.New("missing duration value")
+ }
+ num, err := strconv.Atoi(e.DurNum)
+ if err != nil {
+ return ztime, 0, err
+ }
+ if num <= 0 {
+ return ztime, 0, errors.New("non-positive duration")
+ }
+ var unit time.Duration
+ switch e.DurUnit {
+ case "minutes":
+ unit = time.Minute
+ case "hours":
+ unit = time.Hour
+ case "days":
+ unit = 24 * time.Hour
+ case "weeks":
+ unit = 7 * 24 * time.Hour
+ default:
+ return ztime, 0, fmt.Errorf("bad duration unit %q", e.DurUnit)
+
+ }
+ return ztime, time.Duration(num) * unit, nil
+ default:
+ return ztime, 0, fmt.Errorf("bad radio button value %q", e.Radio)
+ }
+}
+
+// Embed the action template into the binary.
+// We must use the FS form in order to make it trusted with the
+// github.com/google/safehtml/template API.
+
+//go:embed actionlog.tmpl
+var actionLogFS embed.FS
+
+const actionLogTmplFile = "actionlog.tmpl"
+
+// The template name must match the filename.
+var actionLogPageTmpl = template.Must(template.New(actionLogTmplFile).
+ ParseFS(template.TrustedFSFromEmbed(actionLogFS), actionLogTmplFile))
diff --git a/internal/gaby/actionlog.tmpl b/internal/gaby/actionlog.tmpl
new file mode 100644
index 0000000..3db3c81
--- /dev/null
+++ b/internal/gaby/actionlog.tmpl
@@ -0,0 +1,89 @@
+<!--
+Copyright 2024 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>
+ <head>
+ <title>Oscar Action Log</title>
+ <style>
+ body { font-family: sans-serif }
+ </style>
+ </head>
+ <body>
+ <h1>Action Log Viewer</h1>
+
+ <form id="form" action="/actionlog" method="GET">
+ <table>
+ <tr>
+ <td>
+ <fieldset>
+ <legend>Start</legend>
+ <div>
+ <input type="radio" name="start" id="start-fixed" value="fixed"
+ {{if eq .Start.Radio "fixed"}}checked{{end}}/>
+ <label for="start-fixed">Beginning</label>
+ </div>
+ <div>
+ <input type="radio" name="start" id="start-dur" value="dur"
+ {{if eq .Start.Radio "dur"}}checked{{end}}/>
+ <input type="text" id="start-dur" size="4" name="start-dur-num" value="{{.Start.DurNum}}" autofocus/>
+ <select name="start-dur-unit">
+ {{template "dur-unit" .Start.DurUnit}}
+ </select>
+ <label for="start-dur">before end</label>
+ </div>
+ <div>
+ <input type="radio" name="start" id="start-date" value="date"
+ {{if eq .Start.Radio "date"}}checked{{end}}/>
+ <label for="start-date">From</label>
+ <input type="datetime-local" name="start-date" value="{{.Start.Date}}"/>
+ </div>
+ </fieldset>
+ </td>
+
+ <td>
+ <fieldset>
+ <legend>End</legend>
+ <div>
+ <input type="radio" name="end" id="end-fixed" value="fixed"
+ {{if eq .End.Radio "fixed"}}checked{{end}}/>
+ <label for="end-fixed">End</label>
+ </div>
+ <div>
+ <input type="radio" name="end" id="end-dur" value="dur"
+ {{if eq .End.Radio "dur"}}checked{{end}}/>
+ <input type="text" id="end-dur" size="4" name="end-dur-num" value="1" autofocus/>
+ <select name="end-dur-unit">
+ {{template "dur-unit" .End.DurUnit}}
+ </select>
+ <label for="end-dur">after start</label>
+ </div>
+ <div>
+ <input type="radio" name="end" id="end-date" value="date"
+ {{if eq .End.Radio "date"}}checked{{end}}/>
+ <label for="end-date">To</label>
+ <input type="datetime-local" name="end-date" value="{{.End.Date}}"ga/>
+ </div>
+ </fieldset>
+ </td>
+
+ <td> <input type="submit" value="Display"/> </td>
+ </tr>
+ </table>
+ </form>
+
+ {{if .StartTime}}
+ <h2>Action Log from {{.StartTime}} to {{.EndTime}}</h2>
+ {{end}}
+
+ </body>
+</html>
+
+{{define "dur-unit"}}
+ <option value="minutes"{{if eq . "minutes"}} selected{{end}}>minutes</option>
+ <option value="hours"{{if eq . "hours"}} selected{{end}}>hours</option>
+ <option value="days"{{if eq . "days"}} selected{{end}}>days</option>
+ <option value="weeks"{{if eq . "weeks"}} selected{{end}}>weeks</option>
+{{end}}
diff --git a/internal/gaby/actionlog_test.go b/internal/gaby/actionlog_test.go
new file mode 100644
index 0000000..b5a70af
--- /dev/null
+++ b/internal/gaby/actionlog_test.go
@@ -0,0 +1,112 @@
+// Copyright 2024 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 (
+ "bytes"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestTimeOrDuration(t *testing.T) {
+ now := time.Now()
+ for _, test := range []struct {
+ endpoint endpoint
+ wantTime time.Time
+ wantDur time.Duration
+ }{
+ {
+ endpoint: endpoint{Radio: "fixed"},
+ wantTime: time.Time{},
+ },
+ {
+ endpoint: endpoint{Radio: "date", Date: "2018-01-02T09:11"},
+ wantTime: time.Date(2018, 1, 2, 9, 11, 0, 0, time.Local),
+ },
+ {
+ endpoint: endpoint{Radio: "dur", DurNum: "3", DurUnit: "hours"},
+ wantDur: 3 * time.Hour,
+ },
+ } {
+ gotTime, gotDur, err := test.endpoint.timeOrDuration(now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !gotTime.Equal(test.wantTime) || gotDur != test.wantDur {
+ t.Errorf("%+v: got (%s, %s), want (%s, %s)",
+ test.endpoint, gotTime, gotDur, test.wantTime, test.wantDur)
+ }
+ }
+}
+
+func TestTimes(t *testing.T) {
+ dt := func(year int, month time.Month, day, hour, minute int) time.Time {
+ return time.Date(year, month, day, hour, minute, 0, 0, time.Local)
+ }
+
+ now := dt(2024, 9, 10, 0, 0)
+
+ for _, test := range []struct {
+ start, end endpoint
+ wantStart, wantEnd time.Time
+ }{
+ {
+ start: endpoint{Radio: "fixed"},
+ end: endpoint{Radio: "fixed"},
+ wantStart: time.Time{},
+ wantEnd: now,
+ },
+ {
+ start: endpoint{Radio: "dur", DurNum: "1", DurUnit: "hours"},
+ end: endpoint{Radio: "date", Date: "2001-11-12T04:00"},
+ wantStart: dt(2001, 11, 12, 3, 0),
+ wantEnd: dt(2001, 11, 12, 4, 0),
+ },
+ {
+ start: endpoint{Radio: "date", Date: "2001-11-12T04:00"},
+ end: endpoint{Radio: "dur", DurNum: "1", DurUnit: "hours"},
+ wantStart: dt(2001, 11, 12, 4, 0),
+ wantEnd: dt(2001, 11, 12, 5, 0),
+ },
+ {
+ start: endpoint{Radio: "date", Date: "2001-11-12T04:00"},
+ end: endpoint{Radio: "date", Date: "2002-01-02T11:21"},
+ wantStart: dt(2001, 11, 12, 4, 0),
+ wantEnd: dt(2002, 1, 2, 11, 21),
+ },
+ } {
+ gotStart, gotEnd, err := times(test.start, test.end, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !gotStart.Equal(test.wantStart) || !gotEnd.Equal(test.wantEnd) {
+ t.Errorf("times(%+v, %+v):\ngot (%s, %s)\nwant (%s, %s)",
+ test.start, test.end, gotStart, gotEnd, test.wantStart, test.wantEnd)
+ }
+ }
+}
+
+func TestActionTemplate(t *testing.T) {
+ var buf bytes.Buffer
+ page := actionLogPage{
+ Start: endpoint{DurNum: "3", DurUnit: "days"},
+ }
+ if err := actionLogPageTmpl.Execute(&buf, page); err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ wants := []string{
+ `<option value="days" selected>days</option>`,
+ }
+ for _, w := range wants {
+ if !strings.Contains(got, w) {
+ t.Errorf("did not find %q", w)
+ }
+ }
+ if t.Failed() {
+ t.Log(got)
+ }
+}
diff --git a/internal/gaby/main.go b/internal/gaby/main.go
index 2bc0f03..06693ac 100644
--- a/internal/gaby/main.go
+++ b/internal/gaby/main.go
@@ -433,6 +433,9 @@
// POST because the arguments to the request are in the body.
mux.HandleFunc("POST /api/search", g.handleSearchAPI)
+ // /actionlog: display action log
+ mux.HandleFunc("GET /actionlog", g.handleActionLog)
+
return mux
}
diff --git a/internal/gaby/search.go b/internal/gaby/search.go
index 398e697..68517b4 100644
--- a/internal/gaby/search.go
+++ b/internal/gaby/search.go
@@ -56,7 +56,7 @@
// This template assumes that if a result's Kind is non-empty, it is a URL,
// and vice versa.
-var searchPageTmpl = template.Must(template.New("").Parse(`
+var searchPageTmpl = template.Must(template.New("search").Parse(`
<!doctype html>
<html>
<head>