feat(audit): clickable column headers with asc/desc sort
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
stdhttp "net/http"
|
stdhttp "net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,6 +32,19 @@ type auditPage struct {
|
|||||||
UserNames map[string]string // user_id → username for row rendering
|
UserNames map[string]string // user_id → username for row rendering
|
||||||
HostNames map[string]string // host_id → name (for target_kind=host display)
|
HostNames map[string]string // host_id → name (for target_kind=host display)
|
||||||
Actions []string // distinct actions seen so far, for the dropdown
|
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"
|
// 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"),
|
TargetKind: q.Get("target_kind"),
|
||||||
Since: rangeToSince(rng, time.Now().UTC()),
|
Since: rangeToSince(rng, time.Now().UTC()),
|
||||||
Limit: 5000, // CSV export tolerates more rows; HTML clamps via paging later
|
Limit: 5000, // CSV export tolerates more rows; HTML clamps via paging later
|
||||||
|
OrderBy: q.Get("sort"),
|
||||||
|
OrderAsc: q.Get("dir") == "asc",
|
||||||
}, rng
|
}, rng
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,12 +101,63 @@ func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|||||||
return
|
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{
|
page := auditPage{
|
||||||
Filter: f,
|
Filter: f,
|
||||||
Range: rng,
|
Range: rng,
|
||||||
Entries: entries,
|
Entries: entries,
|
||||||
UserNames: map[string]string{},
|
UserNames: map[string]string{},
|
||||||
HostNames: 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 {
|
if users, err := s.deps.Store.ListUsers(r.Context()); err == nil {
|
||||||
for _, ux := range users {
|
for _, ux := range users {
|
||||||
|
|||||||
@@ -43,6 +43,31 @@ func funcMap() template.FuncMap {
|
|||||||
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%v", x)))
|
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 {
|
"derefInt": func(p *int) int {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+31
-1
@@ -21,6 +21,24 @@ type AuditFilter struct {
|
|||||||
Since time.Time // zero = no lower bound
|
Since time.Time // zero = no lower bound
|
||||||
Until time.Time // zero = no upper bound
|
Until time.Time // zero = no upper bound
|
||||||
Limit int // 0 = no limit
|
Limit int // 0 = no limit
|
||||||
|
// OrderBy is one of "ts" | "actor" | "user_id" | "action" |
|
||||||
|
// "target_kind". Empty / unknown falls back to "ts". The
|
||||||
|
// allowlist is enforced inside ListAudit so callers can't
|
||||||
|
// inject SQL via this field.
|
||||||
|
OrderBy string
|
||||||
|
OrderAsc bool // false = DESC (default — newest first)
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditOrderColumn validates f.OrderBy against the column allowlist
|
||||||
|
// and returns the SQL fragment. Unknown / empty → "ts" so callers
|
||||||
|
// always get a deterministic order.
|
||||||
|
func auditOrderColumn(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "actor", "user_id", "action", "target_kind":
|
||||||
|
return s
|
||||||
|
default:
|
||||||
|
return "ts"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAudit returns audit_log rows ordered by ts DESC.
|
// ListAudit returns audit_log rows ordered by ts DESC.
|
||||||
@@ -63,7 +81,19 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, err
|
|||||||
if len(conds) > 0 {
|
if len(conds) > 0 {
|
||||||
q += " WHERE " + strings.Join(conds, " AND ")
|
q += " WHERE " + strings.Join(conds, " AND ")
|
||||||
}
|
}
|
||||||
q += ` ORDER BY ts DESC`
|
col := auditOrderColumn(f.OrderBy)
|
||||||
|
dir := "DESC"
|
||||||
|
if f.OrderAsc {
|
||||||
|
dir = "ASC"
|
||||||
|
}
|
||||||
|
// Always tie-break on ts DESC so equal sort keys (e.g. dozens
|
||||||
|
// of rows with action='alert.resolve') still come back in a
|
||||||
|
// deterministic, time-meaningful order.
|
||||||
|
if col == "ts" {
|
||||||
|
q += fmt.Sprintf(" ORDER BY ts %s", dir)
|
||||||
|
} else {
|
||||||
|
q += fmt.Sprintf(" ORDER BY %s %s, ts DESC", col, dir)
|
||||||
|
}
|
||||||
if f.Limit > 0 {
|
if f.Limit > 0 {
|
||||||
q += ` LIMIT ?`
|
q += ` LIMIT ?`
|
||||||
args = append(args, f.Limit)
|
args = append(args, f.Limit)
|
||||||
|
|||||||
@@ -109,6 +109,54 @@ func TestListAuditFiltersAndOrdering(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListAuditSort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
st, uid := newAuditTestStore(t)
|
||||||
|
t0 := time.Now().UTC()
|
||||||
|
|
||||||
|
appendAudit(t, st, uid, "user", "host.enrolled", "host", "h1", t0.Add(-3*time.Hour))
|
||||||
|
appendAudit(t, st, uid, "user", "alert.acknowledge", "alert", "a1", t0.Add(-time.Hour))
|
||||||
|
appendAudit(t, st, "", "system", "host.auto_init", "host", "h1", t0.Add(-30*time.Minute))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Sort by action ASC.
|
||||||
|
got, err := st.ListAudit(ctx, AuditFilter{OrderBy: "action", OrderAsc: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sort action asc: %v", err)
|
||||||
|
}
|
||||||
|
wantActions := []string{"alert.acknowledge", "host.auto_init", "host.enrolled"}
|
||||||
|
for i, w := range wantActions {
|
||||||
|
if got[i].Action != w {
|
||||||
|
t.Errorf("[%d] action: got %q want %q", i, got[i].Action, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by action DESC.
|
||||||
|
got, _ = st.ListAudit(ctx, AuditFilter{OrderBy: "action", OrderAsc: false})
|
||||||
|
if got[0].Action != "host.enrolled" {
|
||||||
|
t.Errorf("desc head: got %q want host.enrolled", got[0].Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown OrderBy → falls back to ts DESC.
|
||||||
|
got, _ = st.ListAudit(ctx, AuditFilter{OrderBy: "DROP TABLE; --"})
|
||||||
|
if got[0].Action != "host.auto_init" {
|
||||||
|
t.Errorf("unknown OrderBy should fall back to ts DESC; got head %q", got[0].Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by actor — ties tie-break on ts DESC, so 'user' rows
|
||||||
|
// should come back newest-first within the actor group.
|
||||||
|
got, _ = st.ListAudit(ctx, AuditFilter{OrderBy: "actor", OrderAsc: true})
|
||||||
|
// First two are 'system' (1 row) and 'user' (2 rows newest-first):
|
||||||
|
// expect system → user(ack) → user(enrolled)
|
||||||
|
if got[0].Actor != "system" {
|
||||||
|
t.Errorf("actor asc head: got %q want system", got[0].Actor)
|
||||||
|
}
|
||||||
|
if got[1].Action != "alert.acknowledge" {
|
||||||
|
t.Errorf("actor asc tie-break should be ts DESC; got [1]=%q", got[1].Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDistinctAuditActions(t *testing.T) {
|
func TestDistinctAuditActions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
st, uid := newAuditTestStore(t)
|
st, uid := newAuditTestStore(t)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -328,6 +328,16 @@
|
|||||||
text-transform: uppercase; letter-spacing: 0.08em;
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.audit-row.head:hover { background: transparent; }
|
.audit-row.head:hover { background: transparent; }
|
||||||
|
.audit-row.head .sort-header {
|
||||||
|
color: inherit; text-decoration: none; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: baseline; gap: 4px;
|
||||||
|
}
|
||||||
|
.audit-row.head .sort-header:hover { color: var(--ink); }
|
||||||
|
.audit-row.head .sort-glyph {
|
||||||
|
font-size: 9px; color: var(--accent);
|
||||||
|
/* keep the row height stable when the glyph appears/disappears */
|
||||||
|
min-width: 8px; display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- schedule rows (Schedules tab) ---------- */
|
/* ---------- schedule rows (Schedules tab) ---------- */
|
||||||
.schd-row {
|
.schd-row {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
{{/* Export carries the current filter querystring so the
|
{{/* Export carries the current filter querystring so the
|
||||||
download is exactly what the operator sees on screen
|
download is exactly what the operator sees on screen
|
||||||
(up to a higher row cap of 5000 vs 500 in the table). */}}
|
(up to a higher row cap of 5000 vs 500 in the table). */}}
|
||||||
<a href="/audit.csv?range={{$rng}}{{if $filter.UserID}}&user_id={{$filter.UserID}}{{end}}{{if $filter.Actor}}&actor={{$filter.Actor}}{{end}}{{if $filter.ActionLike}}&action={{$filter.ActionLike}}{{end}}{{if $filter.TargetKind}}&target_kind={{$filter.TargetKind}}{{end}}"
|
<a href="{{$page.CSVHref}}"
|
||||||
class="btn"
|
class="btn"
|
||||||
title="Download the current filter as CSV (up to 5000 rows, UTF-8, RFC 4180)">
|
title="Download the current filter as CSV (up to 5000 rows, UTF-8, RFC 4180)">
|
||||||
Export CSV ↓
|
Export CSV ↓
|
||||||
@@ -109,12 +109,31 @@
|
|||||||
{{/* table */}}
|
{{/* table */}}
|
||||||
<div class="panel mt-3.5 rounded-[7px] overflow-hidden">
|
<div class="panel mt-3.5 rounded-[7px] overflow-hidden">
|
||||||
|
|
||||||
|
{{/* Header — every column except the payload one is a clickable
|
||||||
|
sort link. Hrefs are pre-built server-side ($page.SortHrefs)
|
||||||
|
so the URL escaping rules don't trip on the '=' chars when
|
||||||
|
html/template encodes <a href> attributes. */}}
|
||||||
<div class="audit-row head">
|
<div class="audit-row head">
|
||||||
<div>When</div>
|
<div>
|
||||||
<div>Actor</div>
|
<a href="{{index $page.SortHrefs "ts"}}"
|
||||||
<div>User</div>
|
class="sort-header">When <span class="sort-glyph">{{sortGlyph "ts" $page.Sort $page.Dir}}</span></a>
|
||||||
<div>Action</div>
|
</div>
|
||||||
<div>Target</div>
|
<div>
|
||||||
|
<a href="{{index $page.SortHrefs "actor"}}"
|
||||||
|
class="sort-header">Actor <span class="sort-glyph">{{sortGlyph "actor" $page.Sort $page.Dir}}</span></a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{index $page.SortHrefs "user_id"}}"
|
||||||
|
class="sort-header">User <span class="sort-glyph">{{sortGlyph "user_id" $page.Sort $page.Dir}}</span></a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{index $page.SortHrefs "action"}}"
|
||||||
|
class="sort-header">Action <span class="sort-glyph">{{sortGlyph "action" $page.Sort $page.Dir}}</span></a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{index $page.SortHrefs "target_kind"}}"
|
||||||
|
class="sort-header">Target <span class="sort-glyph">{{sortGlyph "target_kind" $page.Sort $page.Dir}}</span></a>
|
||||||
|
</div>
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user