feat(audit): server-side paging, search and category filter; one event format
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 8m2s

The page rendered a map of eleven event types to labels and seven to colours.
The server emits forty-seven. Everything unmapped fell through to its raw
string, so "Key Assigned" in green sat above "workflow.schedule_updated" in
grey — the same kind of fact in two formats, which made the column look like it
carried a meaning it did not.

Presentation is now derived rather than enumerated. Event types are named
<category>.<action> by every call site, so the category becomes a chip, the
action is humanised, and the tone comes from the verb. A type added to the
server tomorrow gets a sensible label and colour with no second list to update;
the override table holds only the dozen the rule reads badly for. Every row is
one treatment, and colour never carries meaning alone — the sentence beside it
says the same thing in words.

Paging and filtering are server-side, unlike the fleet lists that answer with
everything and slice in the browser. audit_retention_days is a licensed
entitlement measured in months, and this log is read to answer questions about
the past, so a browser filtering the most recent page would report "no results"
for events that exist. GET /api/audit now takes q, category, limit and skip and
answers {events, total} — a short page is not evidence of the end of the log,
which is why the total is counted rather than inferred.

audit_logs had no indexes at all: every read was a collection scan with an
in-memory sort over an append-only collection. Adds (instance_id, created_at)
and warns rather than failing, matching EnsureSecretIndexes.

Two bugs found by running the deriver over all forty-seven real types rather
than eyeballing it: the tone rules matched only past-tense verbs, leaving
auth_provider.delete drawn as neutral beside key.deleted in red; and
"unaccepted" matched "accepted", so withdrawing an acceptance read as the same
caution as granting one.
This commit is contained in:
2026-08-10 15:25:48 +01:00
parent 42f3f3e640
commit 675689a458
8 changed files with 465 additions and 107 deletions
+15 -4
View File
@@ -527,18 +527,29 @@ echo "vantage-agent updated to ${VERSION} and restarted."
}
func listAuditEvents(c *gin.Context) {
limit := int64(100)
f := services.AuditFilter{
Search: c.Query("q"),
Category: c.Query("category"),
}
if l := c.Query("limit"); l != "" {
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
limit = n
f.Limit = n
}
}
events, err := services.ListAuditEvents(auth.InstanceID(c), limit)
if s := c.Query("skip"); s != "" {
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n >= 0 {
f.Skip = n
}
}
events, total, err := services.ListAuditEvents(auth.InstanceID(c), f)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, events)
// An object rather than a bare array: a page is meaningless without the
// total it came from, and a short page is not proof of the end of the log.
c.JSON(http.StatusOK, gin.H{"events": events, "total": total})
}
func getSettings(c *gin.Context) {