blob: 1852df0849602db6f68696f8d266e783ad878e32 [file] [log] [blame] [edit]
// 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 (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/safehtml"
"github.com/google/safehtml/template"
"golang.org/x/oscar/internal/actions"
"golang.org/x/oscar/internal/filter"
"golang.org/x/oscar/internal/storage"
)
// actionLogPage is the data for the action log HTML template.
type actionLogPage struct {
CommonPage
Start, End endpoint
StartTime, EndTime string // formatted times that the endpoints describe
Filter string
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.Filter = r.FormValue("filter")
page.Start.formValues(r, "start")
page.End.formValues(r, "end")
browserTimezone := r.FormValue("timezone")
loc, err := time.LoadLocation(browserTimezone)
if err != nil {
return nil, http.StatusInternalServerError, fmt.Errorf("no timezone named %q", browserTimezone)
}
startTime, endTime, err := times(page.Start, page.End, time.Now())
if err != nil {
return nil, http.StatusBadRequest, err
}
startTime = startTime.In(loc)
endTime = endTime.In(loc)
// Retrieve and display entries if something was set.
if r.FormValue("start") != "" {
filter, err := newFilter(page.Filter)
if err != nil {
return nil, http.StatusBadRequest, fmt.Errorf("invalid filter: %v", err)
}
page.Entries = g.actionsBetween(startTime, endTime, filter)
for _, e := range page.Entries {
e.Created = e.Created.In(loc)
e.Done = e.Done.In(loc)
}
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"
}
page.setCommonPage()
b, err := Exec(actionLogPageTmpl, &page)
if err != nil {
return nil, http.StatusInternalServerError, err
}
return b, http.StatusOK, nil
}
func (p *actionLogPage) setCommonPage() {
p.CommonPage = *p.toCommonPage()
}
func (p *actionLogPage) toCommonPage() *CommonPage {
return &CommonPage{
ID: actionlogID,
Description: "Browse actions taken by Oscar.",
Form: Form{
// Unset because the action log page defines its form inputs
// directly in an HTML template.
Inputs: nil,
SubmitText: "display",
},
}
}
// 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()
et, ed, err2 := end.timeOrDuration()
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() (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-02T15: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)
}
}
// actionsBetween returns the action entries between start and end, inclusive.
func (g *Gaby) actionsBetween(start, end time.Time, filter func(*actions.Entry) bool) []*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
}
if filter(e) {
es = append(es, e)
}
}
return es
}
func newFilter(s string) (func(*actions.Entry) bool, error) {
if s == "" {
return func(*actions.Entry) bool { return true }, nil
}
expr, err := filter.ParseFilter(s)
if err != nil {
return nil, err
}
ev, problems := filter.Evaluator[actions.Entry](expr, nil)
if len(problems) > 0 {
return nil, errors.New(strings.Join(problems, "\n"))
}
return func(e *actions.Entry) bool {
if e == nil {
return false
}
return ev(*e)
}, nil
}
// 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)
}
var actionLogPageTmpl = newTemplate(actionLogTmplFile, 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)
},
})