| // 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) |
| }, |
| }) |