internal/gaby: action log page: entries
Display the selected entries on the page.
Since actions may be large, put each action in a row below the
other fields of the entry, and allow the user to toggle its display.
For golang/oscar#9.
Change-Id: I8c8c97c2e808e2dcf94e47e9b8b4adf72ba21ee7
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/616217
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Tatiana Bradley <tatianabradley@google.com>
diff --git a/internal/gaby/actionlog.go b/internal/gaby/actionlog.go
index 7e8b78a..d48c881 100644
--- a/internal/gaby/actionlog.go
+++ b/internal/gaby/actionlog.go
@@ -7,14 +7,17 @@
import (
"bytes"
"embed"
+ "encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
+ "github.com/google/safehtml"
"github.com/google/safehtml/template"
"golang.org/x/oscar/internal/actions"
+ "golang.org/x/oscar/internal/storage"
)
// actionLogPage is the data for the action log HTML template.
@@ -53,8 +56,10 @@
return nil, http.StatusBadRequest, err
}
- // Display a table heading, but only if something was set.
+ // Retrieve and display entries if something was set.
if r.FormValue("start") != "" {
+ page.Entries = g.actionsBetween(startTime, endTime)
+
if startTime.IsZero() {
page.StartTime = "the beginning of time"
} else {
@@ -169,6 +174,42 @@
}
}
+// actionsBetween returns the action entries between start and end, inclusive.
+func (g *Gaby) actionsBetween(start, end time.Time) []*actions.Entry {
+ var es []*actions.Entry
+ // Scan entries created in [start, end].
+ for e := range actions.ScanAfter(g.slog, g.db, start.Add(-time.Nanosecond), nil) {
+ if e.Created.After(end) {
+ break
+ }
+ es = append(es, e)
+ }
+ return es
+}
+
+// fmtTime formats a time for display on the action log page.
+func fmtTime(t time.Time) string {
+ if t.IsZero() {
+ return "-"
+ }
+ return t.Format(time.DateTime)
+}
+
+// fmtValue tries to produce a readable string from a []byte.
+// If the slice contains JSON, it displays it as a multi-line indented string.
+// Otherwise it simply converts the []byte to a string.
+func fmtValue(b []byte) string {
+ var m map[string]any
+ if err := json.Unmarshal(b, &m); err != nil {
+ return string(b)
+ }
+ r, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ return fmt.Sprintf("ERROR: %s", err)
+ }
+ return string(r)
+}
+
// 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.
@@ -180,4 +221,12 @@
// The template name must match the filename.
var actionLogPageTmpl = template.Must(template.New(actionLogTmplFile).
+ Funcs(template.FuncMap{
+ "fmttime": fmtTime,
+ "fmtkey": func(key []byte) string { return storage.Fmt(key) },
+ "fmtval": fmtValue,
+ "safeid": func(s string) safehtml.Identifier {
+ return safehtml.IdentifierFromConstantPrefix("id", s)
+ },
+ }).
ParseFS(template.TrustedFSFromEmbed(actionLogFS), actionLogTmplFile))
diff --git a/internal/gaby/actionlog.tmpl b/internal/gaby/actionlog.tmpl
index 3db3c81..364a3c4 100644
--- a/internal/gaby/actionlog.tmpl
+++ b/internal/gaby/actionlog.tmpl
@@ -9,7 +9,18 @@
<title>Oscar Action Log</title>
<style>
body { font-family: sans-serif }
+ thead { background-color: rgb(220, 220, 220)}
</style>
+ <script>
+ // Toggle the hidden state of a row containing the action.
+ // Also change the button label accordingly..
+ function toggleAction(event) {
+ let row = document.getElementById(event.target.dataset.rowid);
+ row.hidden = !row.hidden;
+ // event.target is the button.
+ event.target.value = row.hidden? 'Show': 'Hide';
+ }
+ </script>
</head>
<body>
<h1>Action Log Viewer</h1>
@@ -18,6 +29,11 @@
<table>
<tr>
<td>
+ {{- /* Resist the temptation to factor out the endpoint controls into
+ a single template. That will require that id values are substituted
+ in, which causes safehtml to complain that they aren't safe identifiers.
+ It ends up being more complicated than it's worth.
+ */ -}}
<fieldset>
<legend>Start</legend>
<div>
@@ -76,8 +92,43 @@
{{if .StartTime}}
<h2>Action Log from {{.StartTime}} to {{.EndTime}}</h2>
- {{end}}
+ {{with .Entries}}
+ <table>
+ <thead>
+ <tr>
+ <th>Created</th>
+ <th>Kind</th>
+ <th>Key</th>
+ <th>Action</th>
+ <th>Done</th>
+ <th>Result</th>
+ <th>Error</th>
+ </tr>
+ </thead>
+ {{range $i, $e := .}}
+ <tr>
+ <td>{{$e.Created | fmttime}}</td>
+ <td>{{$e.Kind}}</td>
+ <td>{{$e.Key | fmtkey}}</td>
+ <td>
+ {{- /* clicking the button shows/hides the action on the following row */ -}}
+ <input type="button" value="Show"
+ data-rowid="id-action-{{$i}}"
+ onclick="toggleAction(event)"/>
+ </td>
+ <td>{{$e.Done | fmttime}}</td>
+ <td>{{$e.Result | fmtval}}</td>
+ <td>{{$e.Error}}</td>
+ </tr>
+ <tr id="{{(print "action-" $i) | safeid}}" hidden="true"><td colspan="7">
+ <td>{{$e.Action | fmtval}}</td>
+ <tr>
+ {{end}}
+ {{else}}
+ No entries.
+ {{end}}
+ {{end}}
</body>
</html>
diff --git a/internal/gaby/actionlog_test.go b/internal/gaby/actionlog_test.go
index b5a70af..f19f9d1 100644
--- a/internal/gaby/actionlog_test.go
+++ b/internal/gaby/actionlog_test.go
@@ -6,9 +6,15 @@
import (
"bytes"
+ "context"
"strings"
"testing"
"time"
+
+ "golang.org/x/oscar/internal/actions"
+ "golang.org/x/oscar/internal/storage"
+ "golang.org/x/oscar/internal/testutil"
+ "rsc.io/ordered"
)
func TestTimeOrDuration(t *testing.T) {
@@ -92,7 +98,15 @@
func TestActionTemplate(t *testing.T) {
var buf bytes.Buffer
page := actionLogPage{
- Start: endpoint{DurNum: "3", DurUnit: "days"},
+ Start: endpoint{DurNum: "3", DurUnit: "days"},
+ StartTime: "whatevs",
+ Entries: []*actions.Entry{
+ {
+ Created: time.Now(),
+ Key: ordered.Encode("P", 22),
+ Action: []byte(`{"Project": "P", "Issue":22, "Fix": "fix"}`),
+ },
+ },
}
if err := actionLogPageTmpl.Execute(&buf, page); err != nil {
t.Fatal(err)
@@ -100,6 +114,8 @@
got := buf.String()
wants := []string{
`<option value="days" selected>days</option>`,
+ `Project`,
+ `Issue`,
}
for _, w := range wants {
if !strings.Contains(got, w) {
@@ -110,3 +126,21 @@
t.Log(got)
}
}
+
+func TestActionsBetween(t *testing.T) {
+ db := storage.MemDB()
+ g := &Gaby{slog: testutil.Slogger(t), db: db}
+ before := actions.Register("actionlog", func(context.Context, []byte) ([]byte, error) {
+ return nil, nil
+ })
+ start := time.Now()
+ before(db, []byte{1}, nil, false)
+ end := time.Now()
+ time.Sleep(100 * time.Millisecond)
+ before(db, []byte{2}, nil, false)
+
+ got := g.actionsBetween(start, end)
+ if len(got) != 1 {
+ t.Errorf("got %d entries, want 1", len(got))
+ }
+}