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