feat(audit): clickable column headers with asc/desc sort

This commit is contained in:
2026-05-05 08:15:22 +01:00
parent deb8b874ca
commit 4f66cc2b34
7 changed files with 207 additions and 8 deletions
+31 -1
View File
@@ -21,6 +21,24 @@ type AuditFilter struct {
Since time.Time // zero = no lower bound
Until time.Time // zero = no upper bound
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.
@@ -63,7 +81,19 @@ func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, err
if len(conds) > 0 {
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 {
q += ` LIMIT ?`
args = append(args, f.Limit)