feat(audit): P3-08 — audit log UI with filters, sort, CSV export, payload modal #12
@@ -207,6 +207,9 @@ func (s *Server) routes(r chi.Router) {
|
||||
// Alert list (JSON variant). Same filter shape as the UI page.
|
||||
r.Get("/alerts", s.handleAPIAlerts)
|
||||
|
||||
// Audit log (JSON variant).
|
||||
r.Get("/audit", s.handleAPIAudit)
|
||||
|
||||
// Notification channel test-fire. Dispatches a synthetic payload
|
||||
// through a single named channel; returns JSON result.
|
||||
r.Post("/notifications/{id}/test", s.handleAPINotificationTest)
|
||||
@@ -317,6 +320,9 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Get("/alerts", s.handleUIAlerts)
|
||||
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
|
||||
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
|
||||
// Audit log (read-only).
|
||||
r.Get("/audit", s.handleUIAudit)
|
||||
r.Get("/audit.csv", s.handleUIAuditCSV)
|
||||
// Settings shell + Notifications sub-tab CRUD.
|
||||
r.Get("/settings", s.handleUISettings)
|
||||
r.Get("/settings/notifications", s.handleUINotificationsList)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// ui_audit.go — Audit log read-only surfaces.
|
||||
//
|
||||
// Routes (wired in server.go):
|
||||
//
|
||||
// GET /audit → handleUIAudit (HTML)
|
||||
// GET /audit.csv → handleUIAuditCSV (CSV download honouring current filters)
|
||||
// GET /api/audit → handleAPIAudit (JSON)
|
||||
//
|
||||
// Filters: user, actor, action (substring), target_kind, time-range
|
||||
// preset (24h | 7d | 30d | all). Page-level live refresh is *not*
|
||||
// added here — audit is append-only and operators inspect history,
|
||||
// not current state.
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
type auditPage struct {
|
||||
Filter store.AuditFilter
|
||||
Range string // "24h" | "7d" | "30d" | "all"
|
||||
Entries []store.AuditEntry
|
||||
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"
|
||||
// (or unrecognised) returns the zero time, meaning "no lower bound".
|
||||
func rangeToSince(r string, now time.Time) time.Time {
|
||||
switch r {
|
||||
case "24h", "":
|
||||
return now.Add(-24 * time.Hour)
|
||||
case "7d":
|
||||
return now.Add(-7 * 24 * time.Hour)
|
||||
case "30d":
|
||||
return now.Add(-30 * 24 * time.Hour)
|
||||
default:
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
// auditFilterFromQuery extracts the AuditFilter + range preset from
|
||||
// the request querystring. Shared by the HTML, CSV, and JSON handlers
|
||||
// so all three honour the same filter URL.
|
||||
func auditFilterFromQuery(r *stdhttp.Request) (store.AuditFilter, string) {
|
||||
q := r.URL.Query()
|
||||
rng := q.Get("range")
|
||||
if rng == "" {
|
||||
rng = "24h"
|
||||
}
|
||||
return store.AuditFilter{
|
||||
UserID: q.Get("user_id"),
|
||||
Actor: q.Get("actor"),
|
||||
ActionLike: strings.TrimSpace(q.Get("action")),
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
f, rng := auditFilterFromQuery(r)
|
||||
// HTML page caps lower than CSV — keeps the table snappy.
|
||||
if f.Limit > 500 {
|
||||
f.Limit = 500
|
||||
}
|
||||
|
||||
entries, err := s.deps.Store.ListAudit(r.Context(), f)
|
||||
if err != nil {
|
||||
slog.Error("ui audit: list", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
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 {
|
||||
page.UserNames[ux.ID] = ux.Username
|
||||
}
|
||||
}
|
||||
if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil {
|
||||
for _, h := range hosts {
|
||||
page.HostNames[h.ID] = h.Name
|
||||
}
|
||||
}
|
||||
if actions, err := s.deps.Store.DistinctAuditActions(r.Context()); err == nil {
|
||||
page.Actions = actions
|
||||
}
|
||||
|
||||
view := s.baseView(r, u)
|
||||
view.Title = "Audit · restic-manager"
|
||||
view.Active = "audit"
|
||||
view.Page = page
|
||||
if err := s.deps.UI.Render(w, "audit", view); err != nil {
|
||||
slog.Error("ui audit: render", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAPIAudit is the JSON variant — same filters as the HTML page.
|
||||
func (s *Server) handleAPIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
if _, ok := s.requireUser(r); !ok {
|
||||
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
|
||||
return
|
||||
}
|
||||
f, _ := auditFilterFromQuery(r)
|
||||
if f.Limit > 500 {
|
||||
f.Limit = 500
|
||||
}
|
||||
entries, err := s.deps.Store.ListAudit(r.Context(), f)
|
||||
if err != nil {
|
||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"entries": entries})
|
||||
}
|
||||
|
||||
// handleUIAuditCSV streams the filtered audit log as CSV. Auth-gated
|
||||
// like the HTML page; honours the same filter querystring so an
|
||||
// operator can refine the view in the browser, hit Export, and get
|
||||
// exactly what's on screen (plus more rows up to the 5000 cap).
|
||||
func (s *Server) handleUIAuditCSV(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
f, _ := auditFilterFromQuery(r)
|
||||
entries, err := s.deps.Store.ListAudit(r.Context(), f)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve user_id → username and host_id → name once for the
|
||||
// human-friendly columns.
|
||||
userNames := map[string]string{}
|
||||
if users, err := s.deps.Store.ListUsers(r.Context()); err == nil {
|
||||
for _, ux := range users {
|
||||
userNames[ux.ID] = ux.Username
|
||||
}
|
||||
}
|
||||
hostNames := map[string]string{}
|
||||
if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil {
|
||||
for _, h := range hosts {
|
||||
hostNames[h.ID] = h.Name
|
||||
}
|
||||
}
|
||||
|
||||
stamp := time.Now().UTC().Format("20060102-150405")
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`attachment; filename="audit-%s.csv"`, stamp))
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
defer cw.Flush()
|
||||
// user_id and target_id are internal ULIDs that carry no meaning
|
||||
// to anyone reading the CSV — the resolved name (or — for system
|
||||
// rows / non-host targets) is what an operator wants. The HTML
|
||||
// page still shows IDs in the Target column for traceability when
|
||||
// no name is available; the CSV is for human reporting only.
|
||||
_ = cw.Write([]string{"timestamp_utc", "actor", "user", "action", "target_kind", "target_name", "payload"})
|
||||
for _, e := range entries {
|
||||
var uname string
|
||||
if e.UserID != nil {
|
||||
uname = userNames[*e.UserID]
|
||||
}
|
||||
var tk, tname string
|
||||
if e.TargetKind != nil {
|
||||
tk = *e.TargetKind
|
||||
}
|
||||
if tk == "host" && e.TargetID != nil {
|
||||
tname = hostNames[*e.TargetID]
|
||||
}
|
||||
payload := ""
|
||||
if len(e.Payload) > 0 {
|
||||
payload = string(e.Payload)
|
||||
}
|
||||
_ = cw.Write([]string{
|
||||
e.TS.UTC().Format("2006-01-02 15:04:05"),
|
||||
e.Actor, uname, e.Action, tk, tname, payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strconv"
|
||||
@@ -25,6 +27,47 @@ func funcMap() template.FuncMap {
|
||||
}
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
},
|
||||
// b64 encodes a json.RawMessage (or any []byte / string) as
|
||||
// base64 — used by audit.html to stash arbitrary JSON in a
|
||||
// data- attribute without fighting html/template's contextual
|
||||
// escaping. JS atob() decodes on click.
|
||||
"b64": func(v any) string {
|
||||
switch x := v.(type) {
|
||||
case json.RawMessage:
|
||||
return base64.StdEncoding.EncodeToString(x)
|
||||
case []byte:
|
||||
return base64.StdEncoding.EncodeToString(x)
|
||||
case string:
|
||||
return base64.StdEncoding.EncodeToString([]byte(x))
|
||||
default:
|
||||
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
|
||||
|
||||
@@ -2,11 +2,173 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditFilter narrows ListAudit. Empty fields match anything.
|
||||
type AuditFilter struct {
|
||||
UserID string // empty matches any user OR system rows
|
||||
Actor string // user | agent | system | "" (any)
|
||||
Action string // exact match (e.g. "host.enrolled")
|
||||
ActionLike string // substring match (e.g. "alert." matches alert.acknowledge / alert.resolve)
|
||||
TargetKind string // host | source_group | alert | notification_channel | "" (any)
|
||||
TargetID string // exact match on target_id
|
||||
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.
|
||||
func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, error) {
|
||||
q := `SELECT id, user_id, actor, action, target_kind, target_id, ts, payload FROM audit_log`
|
||||
conds := []string{}
|
||||
args := []any{}
|
||||
if f.UserID != "" {
|
||||
conds = append(conds, "user_id = ?")
|
||||
args = append(args, f.UserID)
|
||||
}
|
||||
if f.Actor != "" {
|
||||
conds = append(conds, "actor = ?")
|
||||
args = append(args, f.Actor)
|
||||
}
|
||||
if f.Action != "" {
|
||||
conds = append(conds, "action = ?")
|
||||
args = append(args, f.Action)
|
||||
}
|
||||
if f.ActionLike != "" {
|
||||
conds = append(conds, "action LIKE ?")
|
||||
args = append(args, "%"+f.ActionLike+"%")
|
||||
}
|
||||
if f.TargetKind != "" {
|
||||
conds = append(conds, "target_kind = ?")
|
||||
args = append(args, f.TargetKind)
|
||||
}
|
||||
if f.TargetID != "" {
|
||||
conds = append(conds, "target_id = ?")
|
||||
args = append(args, f.TargetID)
|
||||
}
|
||||
if !f.Since.IsZero() {
|
||||
conds = append(conds, "ts >= ?")
|
||||
args = append(args, f.Since.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if !f.Until.IsZero() {
|
||||
conds = append(conds, "ts <= ?")
|
||||
args = append(args, f.Until.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if len(conds) > 0 {
|
||||
q += " WHERE " + strings.Join(conds, " AND ")
|
||||
}
|
||||
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)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list audit: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []AuditEntry
|
||||
for rows.Next() {
|
||||
e, err := scanAuditRow(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DistinctAuditActions returns the set of distinct action strings
|
||||
// currently present in the table — used to populate the action filter
|
||||
// dropdown so the operator picks from what actually exists, not a
|
||||
// hardcoded list that might drift from the codebase.
|
||||
func (s *Store) DistinctAuditActions(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT action FROM audit_log ORDER BY action`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: distinct audit actions: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var a string
|
||||
if err := rows.Scan(&a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanAuditRow(scan func(...any) error) (*AuditEntry, error) {
|
||||
var e AuditEntry
|
||||
var userID, targetKind, targetID, payload sql.NullString
|
||||
var ts string
|
||||
if err := scan(&e.ID, &userID, &e.Actor, &e.Action, &targetKind, &targetID, &ts, &payload); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("store: scan audit: %w", err)
|
||||
}
|
||||
if userID.Valid {
|
||||
v := userID.String
|
||||
e.UserID = &v
|
||||
}
|
||||
if targetKind.Valid {
|
||||
v := targetKind.String
|
||||
e.TargetKind = &v
|
||||
}
|
||||
if targetID.Valid {
|
||||
v := targetID.String
|
||||
e.TargetID = &v
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: parse audit ts: %w", err)
|
||||
}
|
||||
e.TS = t
|
||||
if payload.Valid && payload.String != "" {
|
||||
e.Payload = json.RawMessage(payload.String)
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// AppendAudit records an audit log entry.
|
||||
func (s *Store) AppendAudit(ctx context.Context, e AuditEntry) error {
|
||||
if len(e.Payload) == 0 {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
func newAuditTestStore(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
st, err := Open(context.Background(), filepath.Join(t.TempDir(), "rm.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
uid := ulid.Make().String()
|
||||
if err := st.CreateUser(context.Background(), User{
|
||||
ID: uid, Username: "alice", PasswordHash: "x",
|
||||
Role: RoleOperator, CreatedAt: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return st, uid
|
||||
}
|
||||
|
||||
func appendAudit(t *testing.T, st *Store, uid, actor, action, targetKind, targetID string, ts time.Time) {
|
||||
t.Helper()
|
||||
var u, tk, ti *string
|
||||
if uid != "" {
|
||||
u = &uid
|
||||
}
|
||||
if targetKind != "" {
|
||||
tk = &targetKind
|
||||
}
|
||||
if targetID != "" {
|
||||
ti = &targetID
|
||||
}
|
||||
if err := st.AppendAudit(context.Background(), AuditEntry{
|
||||
ID: ulid.Make().String(), UserID: u, Actor: actor, Action: action,
|
||||
TargetKind: tk, TargetID: ti, TS: ts, Payload: json.RawMessage(`{}`),
|
||||
}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditFiltersAndOrdering(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(-2*time.Hour))
|
||||
appendAudit(t, st, uid, "user", "alert.resolve", "alert", "a1", t0.Add(-time.Hour))
|
||||
appendAudit(t, st, "", "system", "host.auto_init", "host", "h1", t0.Add(-30*time.Minute))
|
||||
|
||||
all, err := st.ListAudit(context.Background(), AuditFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(all) != 4 {
|
||||
t.Fatalf("len: got %d want 4", len(all))
|
||||
}
|
||||
// Ordered ts DESC — most recent first.
|
||||
if all[0].Action != "host.auto_init" || all[3].Action != "host.enrolled" {
|
||||
t.Errorf("ordering: got %s ... %s", all[0].Action, all[3].Action)
|
||||
}
|
||||
|
||||
// Action prefix filter: alert.* → 2 rows.
|
||||
got, err := st.ListAudit(context.Background(), AuditFilter{ActionLike: "alert."})
|
||||
if err != nil {
|
||||
t.Fatalf("filter alert.: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("alert.* filter: got %d want 2", len(got))
|
||||
}
|
||||
|
||||
// User filter excludes system rows.
|
||||
got, _ = st.ListAudit(context.Background(), AuditFilter{UserID: uid})
|
||||
if len(got) != 3 {
|
||||
t.Errorf("user filter: got %d want 3", len(got))
|
||||
}
|
||||
|
||||
// Actor=system isolates the auto_init.
|
||||
got, _ = st.ListAudit(context.Background(), AuditFilter{Actor: "system"})
|
||||
if len(got) != 1 || got[0].Action != "host.auto_init" {
|
||||
t.Errorf("actor=system: got %+v", got)
|
||||
}
|
||||
|
||||
// Target kind filter.
|
||||
got, _ = st.ListAudit(context.Background(), AuditFilter{TargetKind: "alert"})
|
||||
if len(got) != 2 {
|
||||
t.Errorf("target_kind=alert: got %d want 2", len(got))
|
||||
}
|
||||
|
||||
// Time range: last 90m → resolve + auto_init.
|
||||
got, _ = st.ListAudit(context.Background(), AuditFilter{Since: t0.Add(-90 * time.Minute)})
|
||||
if len(got) != 2 {
|
||||
t.Errorf("since 90m: got %d want 2", len(got))
|
||||
}
|
||||
|
||||
// Limit clamps result count.
|
||||
got, _ = st.ListAudit(context.Background(), AuditFilter{Limit: 2})
|
||||
if len(got) != 2 {
|
||||
t.Errorf("limit: got %d want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
st, uid := newAuditTestStore(t)
|
||||
t0 := time.Now().UTC()
|
||||
|
||||
appendAudit(t, st, uid, "user", "host.enrolled", "host", "h1", t0)
|
||||
appendAudit(t, st, uid, "user", "host.enrolled", "host", "h2", t0)
|
||||
appendAudit(t, st, uid, "user", "alert.acknowledge", "alert", "a1", t0)
|
||||
|
||||
got, err := st.DistinctAuditActions(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("distinct: %v", err)
|
||||
}
|
||||
want := []string{"alert.acknowledge", "host.enrolled"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("[%d]: got %q want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,38 @@ func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// ListUsers returns every user, sorted by username. Used by surfaces
|
||||
// that need to render a user-id → username map (audit log filter,
|
||||
// "ack'd by" projections).
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, created_at, last_login_at
|
||||
FROM users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list users: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
var role string
|
||||
var lastLogin sql.NullString
|
||||
var created string
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &created, &lastLogin); err != nil {
|
||||
return nil, fmt.Errorf("store: scan user row: %w", err)
|
||||
}
|
||||
u.Role = Role(role)
|
||||
t, _ := time.Parse(time.RFC3339Nano, created)
|
||||
u.CreatedAt = t
|
||||
if lastLogin.Valid {
|
||||
t, _ := time.Parse(time.RFC3339Nano, lastLogin.String)
|
||||
u.LastLoginAt = &t
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountUsers returns the total number of user rows. The first-run
|
||||
// bootstrap uses this to detect a fresh install.
|
||||
func (s *Store) CountUsers(ctx context.Context) (int, error) {
|
||||
|
||||
@@ -278,9 +278,11 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
|
||||
|
||||
> **As shipped (Playwright sweep, 2026-05-04):** /settings/notifications → 3 channels created (sweep-webhook → local Python sink, sweep-ntfy → ntfy.sh public topic, sweep-smtp → MailHog at 127.0.0.1:1025). Test buttons fire alert.test on each: webhook 200/1ms, ntfy 200/322ms, SMTP 250/3ms. Synthetic critical `backup_failed` raised → /alerts shows row with severity dot, kind chip, host, message, raised/last-seen, Ack + Resolve buttons; nav badge `1`; dashboard critical-alert banner appears with Review→ link; OPEN ALERTS card reads `1 unresolved`. Acknowledge → fan-out to all 3 channels emits alert.acknowledged (verified in webhook sink, MailHog inbox, notification_log); Acknowledged tab shows row with `ack'd by <user>` line. Resolve → fan-out emits alert.resolved across all 3 channels; banner clears; dashboard reads `0 unresolved · all clear`; host alerts column reads —. Three live bugs found and fixed mid-sweep: (a) `enabled` form value lost because hidden+checkbox both named `enabled` and `PostForm.Get` returned the first ("0"); (b) Ack/Resolve handlers stored the state change but never dispatched alert.acknowledged / alert.resolved; (c) `hosts.open_alert_count` projection was never recomputed on Raise/Resolve/AutoResolve, so the dashboard count always read 0.
|
||||
|
||||
### Phase 3 — Audit log UI (not started)
|
||||
### Phase 3 — Audit log UI ✅
|
||||
|
||||
- [ ] **P3-08** (S) Audit log UI with filters (user, action, target, time range)
|
||||
- [x] **P3-08** (S) Audit log UI with filters (user, action, target, time range)
|
||||
|
||||
> **As shipped (2026-05-05):** Read-only `/audit` page (+ `/api/audit` JSON). Filters: time-range presets (24h / 7d / 30d / all), user dropdown (any registered user), actor dropdown (user / agent / system), target-kind dropdown (host / schedule / source_group / alert / notification_channel / job / user), action substring search box. Table columns: when (relative + abstime tooltip), actor tag (user accent / agent green / system grey), user (or em-dash for system rows), action string, target (kind · resolved name for hosts, kind · id otherwise), payload `<details>` block when non-empty. New `Store.ListAudit(AuditFilter)` and `Store.DistinctAuditActions` plus `Store.ListUsers`. Append-only — no edit/delete surface, deliberately.
|
||||
|
||||
### Phase 3 acceptance
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -311,6 +311,34 @@
|
||||
.tag-critical { color: var(--bad); border-color: color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%); }
|
||||
.tag-info { color: var(--ink-mid); }
|
||||
|
||||
/* ---------- audit rows (/audit list) ---------- */
|
||||
.audit-row {
|
||||
display: grid; align-items: center;
|
||||
grid-template-columns: 160px 80px 110px 1.4fr 1.5fr 90px;
|
||||
column-gap: 16px;
|
||||
padding: 11px 16px; font-size: 13px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
.audit-row:hover { background: var(--panel-hi); }
|
||||
.audit-row:last-child { border-bottom: 0; }
|
||||
.audit-row.head {
|
||||
cursor: default; padding-top: 9px; padding-bottom: 9px;
|
||||
font-size: 11px; color: var(--ink-fade);
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
}
|
||||
.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) ---------- */
|
||||
.schd-row {
|
||||
display: grid; align-items: center;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
{{define "title"}}Audit · restic-manager{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$page := .Page}}
|
||||
{{$filter := $page.Filter}}
|
||||
{{$rng := $page.Range}}
|
||||
<div class="max-w-[1280px] mx-auto px-8 pb-14">
|
||||
|
||||
{{/* crumbs */}}
|
||||
<div class="crumbs pt-6">
|
||||
<a href="/">Dashboard</a><span class="sep">/</span>
|
||||
<span class="text-ink-mid">audit</span>
|
||||
</div>
|
||||
|
||||
{{/* page header */}}
|
||||
<div class="flex items-baseline justify-between mt-3.5">
|
||||
<div>
|
||||
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
|
||||
Audit log
|
||||
<span class="text-ink-fade font-normal text-[14px] ml-2">
|
||||
{{len $page.Entries}} entries · last {{if eq $rng "all"}}all-time{{else}}{{$rng}}{{end}}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{{/* Export carries the current filter querystring so the
|
||||
download is exactly what the operator sees on screen
|
||||
(up to a higher row cap of 5000 vs 500 in the table). */}}
|
||||
<a href="{{$page.CSVHref}}"
|
||||
class="btn"
|
||||
title="Download the current filter as CSV (up to 5000 rows, UTF-8, RFC 4180)">
|
||||
Export CSV ↓
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-ink-mute mt-2 leading-[1.55]" style="font-size: 11.5px; max-width: 760px;">
|
||||
Append-only history of every operator action, agent message, and system-driven change.
|
||||
Read-only — entries cannot be edited or deleted.
|
||||
</div>
|
||||
|
||||
{{/* filter strip */}}
|
||||
<div class="panel mt-4 px-4 py-3 rounded-[7px]"
|
||||
style="display: grid; grid-template-columns: auto auto auto auto 1fr; gap: 14px; align-items: center;">
|
||||
|
||||
{{/* time-range pills */}}
|
||||
<div class="inline-flex gap-1 p-[3px]" style="border: 1px solid var(--line-soft); border-radius: 5px;">
|
||||
{{range list "24h" "7d" "30d" "all"}}
|
||||
{{$r := .}}
|
||||
{{$active := eq $r $rng}}
|
||||
<a href="/audit?range={{$r}}{{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}}"
|
||||
class="btn btn-ghost"
|
||||
style="padding: 5px 10px; font-size: 11.5px;{{if $active}} background: var(--panel-hi); color: var(--ink);{{end}}">
|
||||
{{if eq $r "all"}}All{{else}}{{$r}}{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{/* user dropdown */}}
|
||||
<div>
|
||||
<select class="field" style="padding: 6px 10px; font-size: 11.5px; min-width: 140px;"
|
||||
onchange="window.location='/audit?range={{$rng}}&user_id='+this.value+'{{if $filter.Actor}}&actor={{$filter.Actor}}{{end}}{{if $filter.ActionLike}}&action={{$filter.ActionLike}}{{end}}{{if $filter.TargetKind}}&target_kind={{$filter.TargetKind}}{{end}}'">
|
||||
<option value="" {{if eq $filter.UserID ""}}selected{{end}}>User · any</option>
|
||||
{{range $id, $name := $page.UserNames}}
|
||||
<option value="{{$id}}" {{if eq $filter.UserID $id}}selected{{end}}>{{$name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{/* actor dropdown — user/agent/system */}}
|
||||
<div>
|
||||
<select class="field" style="padding: 6px 10px; font-size: 11.5px; min-width: 130px;"
|
||||
onchange="window.location='/audit?range={{$rng}}{{if $filter.UserID}}&user_id={{$filter.UserID}}{{end}}&actor='+this.value+'{{if $filter.ActionLike}}&action={{$filter.ActionLike}}{{end}}{{if $filter.TargetKind}}&target_kind={{$filter.TargetKind}}{{end}}'">
|
||||
<option value="" {{if eq $filter.Actor ""}}selected{{end}}>Actor · any</option>
|
||||
<option value="user" {{if eq $filter.Actor "user"}}selected{{end}}>user</option>
|
||||
<option value="agent" {{if eq $filter.Actor "agent"}}selected{{end}}>agent</option>
|
||||
<option value="system" {{if eq $filter.Actor "system"}}selected{{end}}>system</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{/* target kind dropdown */}}
|
||||
<div>
|
||||
<select class="field" style="padding: 6px 10px; font-size: 11.5px; min-width: 160px;"
|
||||
onchange="window.location='/audit?range={{$rng}}{{if $filter.UserID}}&user_id={{$filter.UserID}}{{end}}{{if $filter.Actor}}&actor={{$filter.Actor}}{{end}}{{if $filter.ActionLike}}&action={{$filter.ActionLike}}{{end}}&target_kind='+this.value">
|
||||
<option value="" {{if eq $filter.TargetKind ""}}selected{{end}}>Target · any</option>
|
||||
<option value="host" {{if eq $filter.TargetKind "host"}}selected{{end}}>host</option>
|
||||
<option value="schedule" {{if eq $filter.TargetKind "schedule"}}selected{{end}}>schedule</option>
|
||||
<option value="source_group" {{if eq $filter.TargetKind "source_group"}}selected{{end}}>source_group</option>
|
||||
<option value="alert" {{if eq $filter.TargetKind "alert"}}selected{{end}}>alert</option>
|
||||
<option value="notification_channel" {{if eq $filter.TargetKind "notification_channel"}}selected{{end}}>notification_channel</option>
|
||||
<option value="job" {{if eq $filter.TargetKind "job"}}selected{{end}}>job</option>
|
||||
<option value="user" {{if eq $filter.TargetKind "user"}}selected{{end}}>user</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{/* action substring search */}}
|
||||
<form method="get" action="/audit">
|
||||
<input type="hidden" name="range" value="{{$rng}}">
|
||||
{{if $filter.UserID}}<input type="hidden" name="user_id" value="{{$filter.UserID}}">{{end}}
|
||||
{{if $filter.Actor}}<input type="hidden" name="actor" value="{{$filter.Actor}}">{{end}}
|
||||
{{if $filter.TargetKind}}<input type="hidden" name="target_kind" value="{{$filter.TargetKind}}">{{end}}
|
||||
<input type="text" name="action" value="{{$filter.ActionLike}}"
|
||||
placeholder="action contains… (e.g. alert., host.)"
|
||||
class="field mono"
|
||||
style="padding: 6px 10px; font-size: 11.5px;">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{/* table */}}
|
||||
<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>
|
||||
<a href="{{index $page.SortHrefs "ts"}}"
|
||||
class="sort-header">When <span class="sort-glyph">{{sortGlyph "ts" $page.Sort $page.Dir}}</span></a>
|
||||
</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>
|
||||
|
||||
{{if eq (len $page.Entries) 0}}
|
||||
<div style="padding: 40px; text-align: center;">
|
||||
<div class="text-ink text-[14px] font-medium">No matching entries.</div>
|
||||
<div class="text-ink-mute text-[12px] mt-1">
|
||||
{{if eq $rng "24h"}}Try widening the time range.{{else}}Adjust filters or pick a longer range.{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{range $page.Entries}}
|
||||
{{$e := .}}
|
||||
<div class="audit-row">
|
||||
<div class="mono text-[12px] text-ink-mute" title="UTC">
|
||||
{{absTime $e.TS}}
|
||||
</div>
|
||||
<div>
|
||||
{{if eq $e.Actor "user"}}<span class="tag" style="background: color-mix(in oklch, var(--accent), transparent 92%); border-color: color-mix(in oklch, var(--accent), transparent 60%); color: var(--accent);">user</span>
|
||||
{{else if eq $e.Actor "agent"}}<span class="tag" style="background: color-mix(in oklch, var(--ok), transparent 92%); border-color: color-mix(in oklch, var(--ok), transparent 60%); color: var(--ok);">agent</span>
|
||||
{{else}}<span class="tag" style="background: color-mix(in oklch, var(--ink-fade), transparent 92%); color: var(--ink-mute);">system</span>{{end}}
|
||||
</div>
|
||||
<div class="mono text-[12px] text-ink-mid">
|
||||
{{if $e.UserID}}{{$un := index $page.UserNames (deref $e.UserID)}}{{if $un}}{{$un}}{{else}}<span class="text-ink-fade">{{deref $e.UserID}}</span>{{end}}{{else}}<span class="text-ink-fade">—</span>{{end}}
|
||||
</div>
|
||||
<div class="mono text-[12px] text-ink">{{$e.Action}}</div>
|
||||
<div class="mono text-[12px] text-ink-mute">
|
||||
{{if $e.TargetKind}}
|
||||
<span class="text-ink-fade">{{deref $e.TargetKind}}</span>
|
||||
{{if $e.TargetID}}
|
||||
{{$tid := deref $e.TargetID}}
|
||||
{{if eq (deref $e.TargetKind) "host"}}{{$hn := index $page.HostNames $tid}}{{if $hn}} · {{$hn}}{{else}} · {{$tid}}{{end}}
|
||||
{{else}} · {{$tid}}{{end}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="text-ink-fade">—</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{{if and $e.Payload (gt (len $e.Payload) 2)}}
|
||||
{{/* Payload is base64-encoded onto a data- attribute to
|
||||
bypass html/template's contextual JS-string escaping
|
||||
(which would double-escape arbitrary JSON inside a
|
||||
<script type="application/json"> block). Decoded by
|
||||
atob() in the modal opener. */}}
|
||||
<button type="button" class="btn"
|
||||
style="font-size: 11px; padding: 3px 8px;"
|
||||
data-payload-action="{{$e.Action}}"
|
||||
data-payload-id="{{$e.ID}}"
|
||||
data-payload-b64="{{b64 $e.Payload}}"
|
||||
onclick="window.__rmAuditOpenPayload(this)">payload ↗</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
{{/* Payload modal — single instance shared by every row. Centred
|
||||
overlay with a max-height; the inner <pre> scrolls when the
|
||||
payload is long. Closes on backdrop click, Escape key, or the
|
||||
× button. Plain JSON is pretty-printed; non-JSON falls back to
|
||||
the raw string. */}}
|
||||
<div id="audit-payload-modal" class="fixed inset-0 z-50 hidden"
|
||||
style="background: rgba(0,0,0,0.55); align-items: center; justify-content: center;"
|
||||
onclick="if (event.target === this) window.__rmAuditClosePayload()">
|
||||
<div class="panel rounded-[7px]"
|
||||
style="width: min(720px, 90vw); max-height: 80vh; display: flex; flex-direction: column;"
|
||||
onclick="event.stopPropagation()">
|
||||
<div class="flex items-center justify-between"
|
||||
style="padding: 14px 18px; border-bottom: 1px solid var(--line-soft);">
|
||||
<div>
|
||||
<div class="text-[13px] font-medium text-ink" id="audit-payload-title">payload</div>
|
||||
<div class="text-[11px] text-ink-fade mono mt-0.5" id="audit-payload-subtitle"></div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn"
|
||||
style="font-size: 11.5px;"
|
||||
onclick="window.__rmAuditCopyPayload()">Copy</button>
|
||||
<button type="button" class="btn"
|
||||
style="font-size: 11.5px;"
|
||||
onclick="window.__rmAuditClosePayload()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="audit-payload-body" class="mono text-[12px] text-ink-mid"
|
||||
style="margin: 0; padding: 16px 18px; overflow: auto; white-space: pre-wrap; word-break: break-all; flex: 1; background: var(--bg);"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var modal = document.getElementById('audit-payload-modal');
|
||||
var bodyEl = document.getElementById('audit-payload-body');
|
||||
var titleEl = document.getElementById('audit-payload-title');
|
||||
var subEl = document.getElementById('audit-payload-subtitle');
|
||||
var current = '';
|
||||
|
||||
window.__rmAuditOpenPayload = function(btn) {
|
||||
var id = btn.getAttribute('data-payload-id');
|
||||
var action = btn.getAttribute('data-payload-action');
|
||||
var b64 = btn.getAttribute('data-payload-b64') || '';
|
||||
var raw = '';
|
||||
try { raw = atob(b64); } catch (e) { raw = ''; }
|
||||
try {
|
||||
current = JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch (e) {
|
||||
current = raw;
|
||||
}
|
||||
bodyEl.textContent = current;
|
||||
titleEl.textContent = action;
|
||||
subEl.textContent = id;
|
||||
modal.style.display = 'flex';
|
||||
modal.classList.remove('hidden');
|
||||
};
|
||||
window.__rmAuditClosePayload = function() {
|
||||
modal.classList.add('hidden');
|
||||
modal.style.display = 'none';
|
||||
};
|
||||
window.__rmAuditCopyPayload = function() {
|
||||
if (!current) return;
|
||||
navigator.clipboard.writeText(current).catch(function() {});
|
||||
};
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||
window.__rmAuditClosePayload();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user