feat(audit): clickable column headers with asc/desc sort
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +32,19 @@ type auditPage struct {
|
||||
UserNames map[string]string // user_id → username for row rendering
|
||||
HostNames map[string]string // host_id → name (for target_kind=host display)
|
||||
Actions []string // distinct actions seen so far, for the dropdown
|
||||
// Sort + Dir reflect the *resolved* sort (after allowlist
|
||||
// validation) so the template can render arrows on the active
|
||||
// column.
|
||||
Sort string // "ts" | "actor" | "user_id" | "action" | "target_kind"
|
||||
Dir string // "asc" | "desc"
|
||||
// SortHrefs is a fully-encoded /audit?…&sort=COL&dir=… for each
|
||||
// sortable column. Built server-side because constructing the
|
||||
// querystring inside a Go html/template <a href="…"> applies
|
||||
// URL-attribute escaping to '=' (turning 'range=all' into
|
||||
// 'range%3dall' on the wire), which loses every filter on click.
|
||||
// CSVHref is the analogous link for the export button.
|
||||
SortHrefs map[string]string
|
||||
CSVHref string
|
||||
}
|
||||
|
||||
// rangeToSince converts the time-range preset to a Since cutoff. "all"
|
||||
@@ -64,6 +78,8 @@ func auditFilterFromQuery(r *stdhttp.Request) (store.AuditFilter, string) {
|
||||
TargetKind: q.Get("target_kind"),
|
||||
Since: rangeToSince(rng, time.Now().UTC()),
|
||||
Limit: 5000, // CSV export tolerates more rows; HTML clamps via paging later
|
||||
OrderBy: q.Get("sort"),
|
||||
OrderAsc: q.Get("dir") == "asc",
|
||||
}, rng
|
||||
}
|
||||
|
||||
@@ -85,12 +101,63 @@ func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the sort key once so the page model and the template
|
||||
// see the same value the SQL just used. f.OrderBy may have been
|
||||
// '' or unknown → 'ts'; the template needs the resolved one.
|
||||
resolvedSort := "ts"
|
||||
switch f.OrderBy {
|
||||
case "actor", "user_id", "action", "target_kind":
|
||||
resolvedSort = f.OrderBy
|
||||
}
|
||||
dir := "desc"
|
||||
if f.OrderAsc {
|
||||
dir = "asc"
|
||||
}
|
||||
// Build the per-column sort hrefs once, so the template only
|
||||
// has to emit them. Each click flips dir on the active column;
|
||||
// any other column starts at desc (newest-first / Z→A).
|
||||
base := url.Values{}
|
||||
if rng != "" {
|
||||
base.Set("range", rng)
|
||||
}
|
||||
if f.UserID != "" {
|
||||
base.Set("user_id", f.UserID)
|
||||
}
|
||||
if f.Actor != "" {
|
||||
base.Set("actor", f.Actor)
|
||||
}
|
||||
if f.ActionLike != "" {
|
||||
base.Set("action", f.ActionLike)
|
||||
}
|
||||
if f.TargetKind != "" {
|
||||
base.Set("target_kind", f.TargetKind)
|
||||
}
|
||||
csvHref := "/audit.csv?" + base.Encode()
|
||||
hrefs := make(map[string]string, 5)
|
||||
for _, col := range []string{"ts", "actor", "user_id", "action", "target_kind"} {
|
||||
v := url.Values{}
|
||||
for k, vs := range base {
|
||||
v[k] = vs
|
||||
}
|
||||
v.Set("sort", col)
|
||||
newDir := "desc"
|
||||
if col == resolvedSort && dir == "desc" {
|
||||
newDir = "asc"
|
||||
}
|
||||
v.Set("dir", newDir)
|
||||
hrefs[col] = "/audit?" + v.Encode()
|
||||
}
|
||||
|
||||
page := auditPage{
|
||||
Filter: f,
|
||||
Range: rng,
|
||||
Entries: entries,
|
||||
UserNames: map[string]string{},
|
||||
HostNames: map[string]string{},
|
||||
Sort: resolvedSort,
|
||||
Dir: dir,
|
||||
SortHrefs: hrefs,
|
||||
CSVHref: csvHref,
|
||||
}
|
||||
if users, err := s.deps.Store.ListUsers(r.Context()); err == nil {
|
||||
for _, ux := range users {
|
||||
|
||||
@@ -43,6 +43,31 @@ func funcMap() template.FuncMap {
|
||||
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%v", x)))
|
||||
}
|
||||
},
|
||||
// sortDir computes the dir param for a sort-header link:
|
||||
// click the active column → toggle asc/desc; click any other
|
||||
// column → start at desc (newest-first / Z→A) since that's
|
||||
// the conventional default for date and frequency-style data.
|
||||
"sortDir": func(thisCol, currentCol, currentDir string) string {
|
||||
if thisCol != currentCol {
|
||||
return "desc"
|
||||
}
|
||||
if currentDir == "asc" {
|
||||
return "desc"
|
||||
}
|
||||
return "asc"
|
||||
},
|
||||
// sortGlyph returns the unicode arrow glyph for the sort
|
||||
// header — empty string for inactive columns so they don't
|
||||
// shout.
|
||||
"sortGlyph": func(thisCol, currentCol, currentDir string) string {
|
||||
if thisCol != currentCol {
|
||||
return ""
|
||||
}
|
||||
if currentDir == "asc" {
|
||||
return "↑"
|
||||
}
|
||||
return "↓"
|
||||
},
|
||||
"derefInt": func(p *int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
|
||||
Reference in New Issue
Block a user