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
+13 -2
View File
@@ -47,5 +47,16 @@ for accountability.
## Getting events out
`GET /api/audit` returns recent events as JSON and accepts a `limit`. There is
no streaming or push export; if you need events in a SIEM, poll that endpoint.
`GET /api/audit` returns one page of events as JSON:
```json
{ "events": [ ... ], "total": 3214 }
```
It accepts `limit` (default 50, maximum 200), `skip`, `q` to search actor,
details and event type, and `category` to match the part of an event type
before the dot — `workflow`, `key`, `server`. `total` counts everything
matching the filter, not the page, so a short page is not the end of the log.
There is no streaming or push export; if you need events in a SIEM, poll that
endpoint, walking `skip` until you have `total`.
+4
View File
@@ -140,6 +140,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure workload indexes: %v", err)
}
if err := services.EnsureAuditIndexes(); err != nil {
log.Printf("warning: failed to ensure audit indexes: %v", err)
}
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
log.Printf("warning: failed to list instances for default step seeding: %v", err)
} else {
+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) {
+105 -6
View File
@@ -3,11 +3,14 @@ package services
import (
"context"
"log"
"regexp"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -29,23 +32,119 @@ func LogEvent(instanceID, eventType, actor, serverID, keyID, details string) {
}
}
func ListAuditEvents(instanceID string, limit int64) ([]models.AuditEvent, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
// AuditFilter narrows a page of the audit log.
//
// Filtering is done here rather than in the browser because the audit log is
// the one collection deliberately kept for months — audit_retention_days is a
// licensed entitlement — and it is read to answer questions about the past
// ("who removed that key in March"). A browser filtering the most recent 200
// rows would answer "no results" for an event that exists, which is worse than
// having no search at all.
type AuditFilter struct {
// Search matches actor, details or event type, case-insensitively.
Search string
// Category matches the segment before the first dot in an event type —
// "workflow", "key", "server". Event types are named consistently enough
// that the prefix is a real grouping rather than a guess.
Category string
Limit int64
Skip int64
}
const (
auditDefaultLimit = 50
auditMaxLimit = 200
)
// ListAuditEvents returns one page of the audit log, newest first, along with
// the total number of events matching the filter.
//
// The total is what the pager needs to say "150 of 3,214", and it is counted
// rather than inferred: a page that comes back short is not evidence of the
// end of the log, only of the end of this page.
func ListAuditEvents(instanceID string, f AuditFilter) ([]models.AuditEvent, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
limit := f.Limit
if limit <= 0 {
limit = auditDefaultLimit
}
if limit > auditMaxLimit {
limit = auditMaxLimit
}
skip := f.Skip
if skip < 0 {
skip = 0
}
filter := bson.M{"instance_id": instanceID}
if cat := strings.TrimSpace(f.Category); cat != "" {
// Anchored and quoted: the category arrives from a query string, and an
// unescaped value would let a caller inject a regex that scans the
// collection for as long as it likes.
filter["event_type"] = bson.M{"$regex": "^" + regexp.QuoteMeta(cat) + `\.`}
}
if q := strings.TrimSpace(f.Search); q != "" {
rx := bson.M{"$regex": regexp.QuoteMeta(q), "$options": "i"}
// Event type is searched alongside actor and details so the raw name is
// still a way in for anyone who knows it, even though the UI shows a
// friendly label.
and := []bson.M{{"$or": []bson.M{
{"actor": rx},
{"details": rx},
{"event_type": rx},
}}}
if existing, ok := filter["event_type"]; ok {
and = append(and, bson.M{"event_type": existing})
delete(filter, "event_type")
}
filter["$and"] = and
}
total, err := db.Col("audit_logs").CountDocuments(ctx, filter)
if err != nil {
return nil, 0, err
}
opts := options.Find().
SetSort(bson.D{{Key: "created_at", Value: -1}}).
SetSkip(skip).
SetLimit(limit)
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{"instance_id": instanceID}, opts)
cursor, err := db.Col("audit_logs").Find(ctx, filter, opts)
if err != nil {
return nil, err
return nil, 0, err
}
defer cursor.Close(ctx)
var events []models.AuditEvent
if err := cursor.All(ctx, &events); err != nil {
return nil, err
return nil, 0, err
}
return events, nil
return events, total, nil
}
// EnsureAuditIndexes declares the audit log's read and sweep indexes.
//
// There were none at all, so every page was a collection scan with an in-memory
// sort over a collection that is only ever appended to and kept for as long as
// the licence allows. Warn rather than fatal, matching EnsureSecretIndexes: a
// missing index is slow, not wrong.
func EnsureAuditIndexes() error {
ctx := context.Background()
idx := []mongo.IndexModel{
// Serves both the page query and its sort.
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "created_at", Value: -1}}},
// The retention sweeper deletes by age within an instance.
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "event_type", Value: 1}, {Key: "created_at", Value: -1}}},
}
if _, err := db.Col("audit_logs").Indexes().CreateMany(ctx, idx); err != nil {
log.Printf("warning: audit_logs indexes: %v", err)
}
return nil
}
+166 -91
View File
@@ -1,108 +1,183 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api, AuditEvent } from "@/lib/api";
import { AsyncBoundary, Card, EmptyState, TableSkeleton } from "@/components/ui";
import { AsyncBoundary, Badge, Card, EmptyState, Pagination, TableSkeleton } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { AUDIT_CATEGORIES, describeAuditEvent, type AuditTone } from "@/lib/auditEvents";
const EVENT_LABELS: Record<string, string> = {
"server.created": "Server Created",
"server.deleted": "Server Deleted",
"server.offline": "Server Offline",
"key.uploaded": "Key Uploaded",
"key.deleted": "Key Deleted",
"key.assigned": "Key Assigned",
"key.revoked": "Key Revoked",
"key.generation_dispatched": "Key Generation",
"agent.update_dispatched": "Agent Updated",
"updates.applied": "Updates Applied",
"settings.updated": "Settings Updated",
};
const inputClass =
"w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
const EVENT_COLOURS: Record<string, string> = {
"server.offline": "text-danger",
"server.deleted": "text-danger",
"key.deleted": "text-danger",
"key.revoked": "text-warning",
"server.created": "text-success",
"key.uploaded": "text-success",
"key.assigned": "text-success",
const TONE_CLASSES: Record<AuditTone, string> = {
danger: "text-danger",
warning: "text-warning",
success: "text-success",
neutral: "text-text-secondary",
};
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
return new Date(dateStr).toLocaleString();
}
function EventTypeBadge({ type }: { type: string }) {
const label = EVENT_LABELS[type] ?? type;
const colour = EVENT_COLOURS[type] ?? "text-text-secondary";
return (
<span className={`font-mono text-xs font-medium ${colour}`}>{label}</span>
);
/*
* One treatment for every event, whatever it is: the subsystem as a chip, then
* what happened, in the same weight and size on every row. Colour carries the
* severity of the act and nothing else, and never carries it alone — the
* sentence beside it says the same thing in words.
*/
function EventCell({ type }: { type: string }) {
const { category, action, tone } = describeAuditEvent(type);
return (
<div className="flex flex-wrap items-center gap-2">
<Badge variant="neutral">{category}</Badge>
<span className={`text-sm font-medium ${TONE_CLASSES[tone]}`}>{action}</span>
</div>
);
}
export default function AuditPage() {
const { data: events, isLoading, error, refetch } = useQuery({
queryKey: ["audit"],
queryFn: () => api.listAuditEvents(200),
refetchInterval: 30_000,
});
const [search, setSearch] = useState("");
const [category, setCategory] = useState("");
const [page, setPage] = useState(1);
const [size, setSize] = useState(50);
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
<p className="mt-1 text-sm text-text-secondary">
All administrative actions and server status changes
</p>
</div>
// Debounced so a query is not issued per keystroke against a collection
// that is scanned by regex.
const [debounced, setDebounced] = useState("");
useEffect(() => {
const t = setTimeout(() => setDebounced(search), 300);
return () => clearTimeout(t);
}, [search]);
<Card padding={false}>
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={5} />}
isEmpty={!events || events.length === 0}
empty={
<EmptyState
title="No audit events recorded yet."
description="Every mutating action — a key assigned, a workflow run, a member added — is written here as it happens."
/>
}
>
<Table>
<Thead>
<Tr>
<Th>Time</Th>
<Th>Event</Th>
<Th>Actor</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{events?.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td label="Time">
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
{formatDate(e.created_at)}
</span>
</Td>
<Td label="Event">
<EventTypeBadge type={e.event_type} />
</Td>
<Td label="Actor">
<span className="text-sm text-text-primary">{e.actor}</span>
</Td>
<Td label="Details">
<span className="text-sm text-text-secondary">{e.details}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
</AsyncBoundary>
</Card>
</div>
);
// Any change to what is being asked for returns to the first page. Staying
// on page 7 of a narrower result set shows an empty table that reads as
// "no matches" when the matches are three pages back.
useEffect(() => {
setPage(1);
}, [debounced, category, size]);
const { data, isLoading, isFetching, error, refetch } = useQuery({
queryKey: ["audit", debounced, category, page, size],
queryFn: () => api.listAuditEvents({ q: debounced, category, limit: size, skip: (page - 1) * size }),
refetchInterval: 30_000,
// The table holds its previous contents while the next page loads,
// rather than collapsing to a skeleton on every keystroke and page turn.
placeholderData: keepPreviousData,
});
const events = data?.events ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / size));
const filtered = debounced !== "" || category !== "";
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
<p className="mt-1 text-sm text-text-secondary">All administrative actions and server status changes</p>
</div>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search actor, details or event type…"
aria-label="Search the audit log"
className={`${inputClass} sm:max-w-sm`}
/>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
aria-label="Filter by category"
className={`${inputClass} sm:w-56`}
>
<option value="">All categories</option>
{AUDIT_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
{/* Reserved width, so the row does not reflow each time a
background refetch starts and stops. */}
<span className="min-w-24 font-mono text-xs text-text-tertiary" aria-live="polite">
{isFetching && !isLoading ? "Searching…" : total > 0 ? `${total.toLocaleString()} events` : ""}
</span>
</div>
<Card padding={false}>
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={4} />}
isEmpty={events.length === 0}
empty={
filtered ? (
<EmptyState
title="No events match this search."
description="Searching covers the whole retained log, not just the page on screen, so this means there is nothing to find. Try a shorter term or a different category."
action={{
label: "Clear filters",
onClick: () => {
setSearch("");
setCategory("");
},
}}
/>
) : (
<EmptyState
title="No audit events recorded yet."
description="Every mutating action — a key assigned, a workflow run, a member added — is written here as it happens."
/>
)
}
>
<Table>
<Thead>
<Tr>
<Th>Time</Th>
<Th>Event</Th>
<Th>Actor</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{events.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td label="Time">
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
{formatDate(e.created_at)}
</span>
</Td>
<Td label="Event">
<EventCell type={e.event_type} />
</Td>
<Td label="Actor">
<span className="text-sm text-text-primary">{e.actor}</span>
</Td>
<Td label="Details">
<span className="text-sm text-text-secondary">{e.details}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
<Pagination
page={page}
pageCount={pageCount}
size={size}
total={total}
onPage={setPage}
onSize={setSize}
unit="events"
/>
</AsyncBoundary>
</Card>
</div>
);
}
+26 -3
View File
@@ -164,6 +164,24 @@ export interface AuditEvent {
created_at: string;
}
export interface AuditQuery {
q?: string;
category?: string;
limit?: number;
skip?: number;
}
/*
* The audit log pages on the server, unlike the fleet endpoints that answer
* with everything and slice in the browser. It is kept for months and read to
* answer questions about the past, so a search that only saw the most recent
* page would report "no results" for events that exist.
*/
export interface AuditPage {
events: AuditEvent[];
total: number;
}
export interface AlertSettings {
offline_threshold_minutes: number;
offline_channel_ids: string[] | null;
@@ -651,9 +669,14 @@ export const api = {
});
},
listAuditEvents(limit?: number): Promise<AuditEvent[]> {
const qs = limit ? `?limit=${limit}` : "";
return request<AuditEvent[]>(`/audit${qs}`);
listAuditEvents(params: AuditQuery = {}): Promise<AuditPage> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.category) qs.set("category", params.category);
if (params.limit) qs.set("limit", String(params.limit));
if (params.skip) qs.set("skip", String(params.skip));
const suffix = qs.toString();
return request<AuditPage>(`/audit${suffix ? `?${suffix}` : ""}`);
},
getSettings(): Promise<Settings> {
+135
View File
@@ -0,0 +1,135 @@
/*
* Audit event types, presented.
*
* The page used to hold a map of eleven event types to labels and a second map
* of seven to colours. The server emits forty-seven. Everything unmapped fell
* through to the raw string, so one row read "Key Assigned" in green and the
* next "workflow.schedule_updated" in grey — the same kind of fact in two
* different formats, which made the column look like it carried a meaning it
* did not.
*
* So this derives rather than enumerates. Event types are named
* `<category>.<action>` consistently by every call site, which is a convention
* worth leaning on: the category names the subsystem, the action is
* humanised, and the tone comes from the verb. A type added to the server
* tomorrow gets a sensible label and colour here today, with no second list to
* remember. OVERRIDES exists only for the handful the rule reads badly for.
*/
export type AuditTone = "danger" | "warning" | "success" | "neutral";
/*
* The categories, in the order the sidebar presents their subsystems. Names
* are what the operator calls them, so `secretgroup` and `secret` collapse to
* one entry and `auth_provider` is "Single sign-on" rather than its identifier.
*/
export const AUDIT_CATEGORIES: { value: string; label: string }[] = [
{ value: "server", label: "Servers" },
{ value: "key", label: "SSH keys" },
{ value: "workflow", label: "Workflows" },
{ value: "monitor", label: "Monitors" },
{ value: "secret", label: "Secrets" },
{ value: "secretgroup", label: "Secret groups" },
{ value: "secrets", label: "Secrets access" },
{ value: "vuln", label: "Vulnerabilities" },
{ value: "workload", label: "Workloads" },
{ value: "console", label: "Console" },
{ value: "agent", label: "Agents" },
{ value: "updates", label: "OS updates" },
{ value: "auth_provider", label: "Single sign-on" },
{ value: "settings", label: "Settings" },
{ value: "license", label: "Licence" },
{ value: "instance", label: "Instance" },
];
const CATEGORY_LABELS = new Map(AUDIT_CATEGORIES.map((c) => [c.value, c.label]));
/*
* Tone is taken from the action verb, not the category: deleting a key and
* deleting a workflow are the same weight of act.
*
* Stems match with or without their past tense, because the two spellings both
* occur — `auth_provider.delete` beside `key.deleted`, `workload.stop` beside
* `workflow.schedule_disabled`. Matching only the past tense left half the
* destructive events drawn in the same grey as a settings change.
*
* `accepted` is anchored because `unaccepted` contains it: unanchored, the
* negation matched its own root and withdrawing an acceptance was drawn as the
* same caution as granting one.
*/
const TONE_RULES: [RegExp, AuditTone][] = [
[/(delete|revoke|fail|offline|reap|cancel|destroy|remove)/, "danger"],
// Accepting a finding is a decision to live with a known risk, so it reads
// as a caution rather than an achievement. Withdrawing one falls through to
// neutral: it puts the finding back where it started.
[/(skip|disable|expire|stop|(^|_)accepted)/, "warning"],
[/(create|upload|assign|appl|open|enable|import|start|restart|sync)/, "success"],
];
/*
* Only where the derived text is wrong or reads clumsily. Anything absent is
* derived, which is the point — this list should stay short.
*/
const OVERRIDES: Record<string, string> = {
"key.generation_dispatched": "Key generation requested",
// These three arrive as `"workload." + action`, so the action really is a
// bare imperative rather than a name anyone chose.
"workload.start": "Workload started",
"workload.stop": "Workload stopped",
"workload.restart": "Workload restarted",
// The provider events are named in the imperative where every other
// subsystem uses the past tense; say what happened, like the rest.
"auth_provider.create": "Provider added",
"auth_provider.update": "Provider updated",
"auth_provider.delete": "Provider removed",
"agent.update_dispatched": "Agent update sent",
"updates.applied": "OS updates applied",
"secrets.token_rotated": "Read token rotated",
"secret.revealed": "Secret revealed",
"secretgroup.deleted": "Secret group deleted",
"workflow.defaults_synced": "Default steps synced",
"workflow.run_triggered": "Run started",
"workflow.run_cancelled": "Run cancelled",
"workflow.scheduled_run": "Scheduled run started",
"workflow.schedule_skipped": "Scheduled run skipped",
"auth_provider.ack_notice": "Callback change acknowledged",
"console.proxy_failed": "Console relay failed",
"console.proxy_opened": "Console relay opened",
"instance.reaped": "Instance deleted",
"vuln.rescan": "Rescan requested",
"workload.logs_read": "Workload logs read",
};
export interface AuditEventDisplay {
/** The subsystem, for the chip: "Workflows". */
category: string;
/** What happened, sentence case: "Schedule updated". */
action: string;
tone: AuditTone;
}
export function describeAuditEvent(eventType: string): AuditEventDisplay {
const dot = eventType.indexOf(".");
const prefix = dot === -1 ? "" : eventType.slice(0, dot);
const rest = dot === -1 ? eventType : eventType.slice(dot + 1);
const tone = TONE_RULES.find(([re]) => re.test(rest))?.[1] ?? "neutral";
const override = OVERRIDES[eventType];
const action = override ?? sentenceCase(rest);
return {
// An unknown prefix is shown as itself rather than hidden: a category
// this file has not been taught about is still better named by the
// server's own word for it than by nothing.
category: CATEGORY_LABELS.get(prefix) ?? sentenceCase(prefix || "Event"),
action,
tone,
};
}
function sentenceCase(s: string): string {
const words = s.replace(/[._]/g, " ").trim();
if (!words) return "";
return words.charAt(0).toUpperCase() + words.slice(1);
}
File diff suppressed because one or more lines are too long