From 9c7a8ffe9bf995812ee9c73ec89cb4771ec87c14 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:31:29 +0100 Subject: [PATCH 01/11] feat(audit-log): split changes from data access, add resource/action/lookup filters The audit log listed every telemetry read and MCP tool call beside the changes, so the entries an admin opens the page for were buried. The list now opens on Changes (data access hidden unless it was denied), with Data access and All one click away, backed by a new kind param on GET /v2/audit_log. Also: resource type, action and affected user filters, a lookup field that turns a pasted id or action into its filter, chips for filters with no control of their own, a working Custom range, and a /lab/audit-log page over fixtures. --- apps/api/src/routes/v2/audit-log.http.ts | 3 + .../settings/audit-log-filters.test.ts | 57 ++++ .../components/settings/audit-log-filters.ts | 84 ++++- apps/web/src/lab/audit-log-fixture.ts | 251 ++++++++++++++ apps/web/src/lab/audit-log-lab.ts | 181 +++++++++++ apps/web/src/lab/entries/audit-log.ts | 16 + apps/web/src/lab/registry.ts | 8 + .../audit-log/audit-log.scene.test.ts | 129 +++++++- apps/web/src/page/settings/audit-log/model.ts | 33 ++ .../web/src/page/settings/audit-log/update.ts | 227 +++++++++++-- apps/web/src/page/settings/audit-log/view.ts | 305 +++++++++++++++--- apps/web/src/route/search.ts | 7 + .../services/audit/AuditLogService.test.ts | 59 ++++ .../src/services/audit/AuditLogService.ts | 38 ++- packages/domain/src/http/v2/audit-log.ts | 13 + .../query-engine/src/ch/queries/audit-log.ts | 16 +- packages/views/src/ui/compact-search-input.ts | 12 + 17 files changed, 1349 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/lab/audit-log-fixture.ts create mode 100644 apps/web/src/lab/audit-log-lab.ts create mode 100644 apps/web/src/lab/entries/audit-log.ts diff --git a/apps/api/src/routes/v2/audit-log.http.ts b/apps/api/src/routes/v2/audit-log.http.ts index 5bfc00cf92..cbaa3bcee4 100644 --- a/apps/api/src/routes/v2/audit-log.http.ts +++ b/apps/api/src/routes/v2/audit-log.http.ts @@ -206,6 +206,9 @@ export const HttpV2AuditLogLive = HttpApiBuilder.group(MapleApiV2, "auditLog", ( const page = yield* paginateOffsetQuery(query, ({ limit, offset }) => audit .list(tenant.orgId, { + ...(query.kind !== undefined && query.kind !== "all" + ? { actionKind: query.kind } + : undefined), ...(query.actor_type !== undefined ? { actorType: query.actor_type } : undefined), ...identity, ...(affectedUser !== undefined ? { affectedUserId: affectedUser } : undefined), diff --git a/apps/web/src/components/settings/audit-log-filters.test.ts b/apps/web/src/components/settings/audit-log-filters.test.ts index 9426cf5b19..3cc86da397 100644 --- a/apps/web/src/components/settings/audit-log-filters.test.ts +++ b/apps/web/src/components/settings/audit-log-filters.test.ts @@ -1,9 +1,11 @@ +import { Option } from "effect" import { describe, expect, it } from "vitest" import { auditLogSearchFrom, hasAuditLogFilters, parseAuditLogSearch, + parseAuditLookup, pickAuditLogSearch, resolveAuditDateRange, } from "./audit-log-filters" @@ -111,3 +113,58 @@ describe("resolveAuditDateRange", () => { expect(resolveAuditDateRange({ kind: "all" }, now)).toEqual({ since: undefined, until: undefined }) }) }) + +describe("the resource, action, request and kind filters", () => { + it("keeps known values and round-trips them through the URL", () => { + const search = { + kind: "access", + resource_type: "dashboard", + action: "dashboard.updated", + resource: "dash_YofPTrK9782DWwcnXhpcCw", + affected: "user_2fj3K9dLqWm8xYbT", + request: "8f2c1a9d4b7e3f60", + } + const filters = parseAuditLogSearch(search) + expect(filters).toMatchObject({ + kind: "access", + resourceType: "dashboard", + action: "dashboard.updated", + resourceId: "dash_YofPTrK9782DWwcnXhpcCw", + affectedUserId: "user_2fj3K9dLqWm8xYbT", + requestId: "8f2c1a9d4b7e3f60", + }) + expect(auditLogSearchFrom(filters)).toMatchObject(search) + }) + + it("drops unknown resource types and actions, and leaves the default kind out of the URL", () => { + const filters = parseAuditLogSearch({ + kind: "changes", + resource_type: "spaceship", + action: "dashboard.launched", + request: "not-a-ray", + }) + expect(filters.kind).toBeUndefined() + expect(filters.resourceType).toBeUndefined() + expect(filters.action).toBeUndefined() + expect(filters.requestId).toBeUndefined() + expect(auditLogSearchFrom(filters).kind).toBeUndefined() + }) +}) + +describe("parseAuditLookup", () => { + it("reads each id shape as its filter", () => { + expect(Option.getOrNull(parseAuditLookup("user_2fj3K9dLqWm8xYbT"))).toEqual({ + key: "actorId", + value: "user_2fj3K9dLqWm8xYbT", + }) + expect(Option.getOrNull(parseAuditLookup("alrt_YofPTrK9782DWwcnXhpcCw"))).toEqual({ + key: "resourceId", + value: "alrt_YofPTrK9782DWwcnXhpcCw", + }) + expect(Option.getOrNull(parseAuditLookup("api_key.revoked"))).toEqual({ + key: "action", + value: "api_key.revoked", + }) + expect(Option.isNone(parseAuditLookup("dashboards"))).toBe(true) + }) +}) diff --git a/apps/web/src/components/settings/audit-log-filters.ts b/apps/web/src/components/settings/audit-log-filters.ts index 0cebb89c53..a35656a7ff 100644 --- a/apps/web/src/components/settings/audit-log-filters.ts +++ b/apps/web/src/components/settings/audit-log-filters.ts @@ -1,5 +1,6 @@ +import { auditActionLabel, auditResourceLabel } from "@maple/domain/audit-actions" import type { AuditActorType, AuditOutcome } from "@maple/domain/http" -import { Array as Arr, Option } from "effect" +import { Array as Arr, Option, String as Str } from "effect" export interface AuditLogSearch { readonly range?: string @@ -8,6 +9,12 @@ export interface AuditLogSearch { readonly actor?: string readonly actor_type?: string readonly outcome?: string + readonly kind?: string + readonly resource_type?: string + readonly action?: string + readonly resource?: string + readonly affected?: string + readonly request?: string } /** Every key present, unset ones `undefined`, so spreading it over the search clears them. */ @@ -18,6 +25,12 @@ export interface AuditLogSearchUpdate { readonly actor: string | undefined readonly actor_type: string | undefined readonly outcome: string | undefined + readonly kind: string | undefined + readonly resource_type: string | undefined + readonly action: string | undefined + readonly resource: string | undefined + readonly affected: string | undefined + readonly request: string | undefined } export const AUDIT_RANGE_PRESETS = [ @@ -32,6 +45,10 @@ export type AuditRangePreset = (typeof AUDIT_RANGE_PRESETS)[number]["value"] const ACTOR_TYPES: ReadonlyArray = ["user", "api_key", "agent", "system"] const OUTCOMES: ReadonlyArray = ["allowed", "denied"] +/** Which part of the log the list shows. `changes` is the default and stays out of the URL. */ +export type AuditLogKind = "changes" | "access" | "all" +const KINDS: ReadonlyArray = ["changes", "access", "all"] + /** The window: a rolling preset, a fixed `since`/`until` pair, or everything. */ export type AuditDateRange = | { readonly kind: "all" } @@ -43,8 +60,18 @@ export interface AuditLogFilters { readonly actorId?: string | undefined readonly actorType?: AuditActorType | undefined readonly outcome?: AuditOutcome | undefined + readonly kind?: AuditLogKind | undefined + readonly resourceType?: string | undefined + readonly action?: string | undefined + /** A resource's public id (`dash_…`). */ + readonly resourceId?: string | undefined + readonly affectedUserId?: string | undefined + readonly requestId?: string | undefined } +/** The list's kind, `changes` when the URL names none. */ +export const auditLogKindOf = (filters: AuditLogFilters): AuditLogKind => filters.kind ?? "changes" + // The API takes explicit-UTC ISO timestamps only; anything else would be a 400. const isIsoTimestamp = (value: string | undefined): value is string => value !== undefined && /^\d{4}-\d{2}-\d{2}T.+Z$/.test(value) && !Number.isNaN(Date.parse(value)) @@ -53,6 +80,23 @@ const isIsoTimestamp = (value: string | undefined): value is string => const isActorId = (value: string | undefined): value is string => value !== undefined && /^(?:user|key|actor)_[A-Za-z0-9]+$/.test(value) +const isUserId = (value: string | undefined): value is string => + value !== undefined && /^user_[A-Za-z0-9]+$/.test(value) + +// Public resource ids: a lowercase prefix and a base58 body (`dash_…`, `alrt_…`). +const isPublicId = (value: string | undefined): value is string => + value !== undefined && /^[a-z]+_[A-Za-z0-9]{6,}$/.test(value) + +// Request ids are Cloudflare ray ids: hex, sometimes with a `-` suffix. +const isRequestId = (value: string | undefined): value is string => + value !== undefined && /^[0-9a-f]{16}(?:-[A-Z]{3})?$/.test(value) + +const isKnownAction = (value: string | undefined): value is string => + value !== undefined && auditActionLabel(value) !== null + +const isKnownResourceType = (value: string | undefined): value is string => + value !== undefined && auditResourceLabel(value) !== null + /** Narrow the raw search params to filters the API accepts, dropping anything malformed. */ export function parseAuditLogSearch(search: AuditLogSearch): AuditLogFilters { const since = isIsoTimestamp(search.since) ? search.since : undefined @@ -72,9 +116,34 @@ export function parseAuditLogSearch(search: AuditLogSearch): AuditLogFilters { actorId: isActorId(search.actor) ? search.actor : undefined, actorType: Option.getOrUndefined(Arr.findFirst(ACTOR_TYPES, (type) => type === search.actor_type)), outcome: Option.getOrUndefined(Arr.findFirst(OUTCOMES, (value) => value === search.outcome)), + kind: Option.getOrUndefined( + Arr.findFirst(KINDS, (value) => value !== "changes" && value === search.kind), + ), + resourceType: isKnownResourceType(search.resource_type) ? search.resource_type : undefined, + action: isKnownAction(search.action) ? search.action : undefined, + resourceId: isPublicId(search.resource) ? search.resource : undefined, + affectedUserId: isUserId(search.affected) ? search.affected : undefined, + requestId: isRequestId(search.request) ? search.request : undefined, } } +/** The filter one pasted value stands for: an id of a known shape, or an action name. */ +export type AuditLookup = + | { readonly key: "actorId"; readonly value: string } + | { readonly key: "resourceId"; readonly value: string } + | { readonly key: "requestId"; readonly value: string } + | { readonly key: "action"; readonly value: string } + +/** Read a pasted id or action as the filter it stands for; none for anything else. */ +export function parseAuditLookup(raw: string): Option.Option { + const value = Str.trim(raw) + if (isActorId(value)) return Option.some({ key: "actorId", value }) + if (isRequestId(value)) return Option.some({ key: "requestId", value }) + if (isKnownAction(value)) return Option.some({ key: "action", value }) + if (isPublicId(value)) return Option.some({ key: "resourceId", value }) + return Option.none() +} + /** Write filters back as search params; unset ones are `undefined` so the URL drops them. */ export function auditLogSearchFrom(filters: AuditLogFilters): AuditLogSearchUpdate { const { range } = filters @@ -85,6 +154,12 @@ export function auditLogSearchFrom(filters: AuditLogFilters): AuditLogSearchUpda actor: filters.actorId, actor_type: filters.actorType, outcome: filters.outcome, + kind: filters.kind === "changes" ? undefined : filters.kind, + resource_type: filters.resourceType, + action: filters.action, + resource: filters.resourceId, + affected: filters.affectedUserId, + request: filters.requestId, } } @@ -117,6 +192,11 @@ export function hasAuditLogFilters(filters: AuditLogFilters): boolean { filters.range.kind !== "all" || filters.actorId !== undefined || filters.actorType !== undefined || - filters.outcome !== undefined + filters.outcome !== undefined || + filters.resourceType !== undefined || + filters.action !== undefined || + filters.resourceId !== undefined || + filters.affectedUserId !== undefined || + filters.requestId !== undefined ) } diff --git a/apps/web/src/lab/audit-log-fixture.ts b/apps/web/src/lab/audit-log-fixture.ts new file mode 100644 index 0000000000..688198668c --- /dev/null +++ b/apps/web/src/lab/audit-log-fixture.ts @@ -0,0 +1,251 @@ +import { encodePublicId, type PublicIdPrefix, PublicIdPrefixes, V2AuditLogEntry } from "@maple/domain/http/v2" +import { Array as Arr, Schema, String as Str } from "effect" + +// Audit log entries for `/lab/audit-log`: a morning of changes, reads, tool calls and refusals +// across users, an API key, an agent and Maple itself. + +export const AUDIT_NOW = Date.UTC(2026, 10, 9, 15, 30, 0) + +export const AUDIT_MEMBERS = [ + { value: "user_2fj3K9dLqWm8xYbT", label: "Ada Lovelace" }, + { value: "user_7HqLm2NpRt4VwXyZ", label: "Grace Hopper" }, + { value: "user_9KdTq3RsVw5XyZaB", label: "Linus Torvalds" }, +] as const + +const ADA = AUDIT_MEMBERS[0].value +const GRACE = AUDIT_MEMBERS[1].value +const LINUS = AUDIT_MEMBERS[2].value + +const decode = Schema.decodeUnknownSync(V2AuditLogEntry) + +const uuid = (n: number) => `3f1b7c02-9a44-4d1e-8b2f-${Str.padStart(12, "0")(String(n))}` + +type Wire = Readonly<{ + n: number + minutesAgo: number + action: string + actor: "ada" | "grace" | "linus" | "key" | "agent" | "system" + source: "dashboard" | "api" | "mcp" | "chat_platform" | "system" + resource?: { type: string; prefix?: PublicIdPrefix } + denied?: string + affected?: string + changes?: { + fields: ReadonlyArray + before: Record + after: Record + } + metadata?: Record + ip?: string + country?: string +}> + +const ACTORS = { + ada: { actor_type: "user", actor_id: ADA, actor_name: "Ada Lovelace" }, + grace: { actor_type: "user", actor_id: GRACE, actor_name: "Grace Hopper" }, + linus: { actor_type: "user", actor_id: LINUS, actor_name: null }, + key: { actor_type: "api_key", actor_id: "key_aXwpxqBkqtYwtBtmsGbR41", actor_name: "CI deploy key" }, + agent: { actor_type: "agent", actor_id: "actor_4CzLmR1pTxWvYbNhQd82Kf", actor_name: "Claude Code" }, + system: { actor_type: "system", actor_id: null, actor_name: null }, +} as const + +const entry = (wire: Wire): V2AuditLogEntry => { + const occurred = new Date(AUDIT_NOW - wire.minutesAgo * 60_000).toISOString() + return decode({ + id: encodePublicId(PublicIdPrefixes.auditLogEntry, uuid(wire.n)), + object: "audit_log_entry", + action: wire.action, + outcome: wire.denied === undefined ? "allowed" : "denied", + denial_reason: wire.denied ?? null, + ...ACTORS[wire.actor], + actor_avatar_url: null, + affected_user: wire.affected ?? null, + source: wire.source, + resource_type: wire.resource?.type ?? null, + resource_id: + wire.resource?.prefix === undefined + ? null + : encodePublicId(wire.resource.prefix, uuid(wire.n + 100)), + changes: wire.changes ?? null, + metadata: wire.metadata ?? null, + request_id: wire.source === "system" ? null : `8f2c1a9d4b7e${Str.padStart(4, "0")(String(wire.n))}`, + origin_ip: wire.source === "system" ? null : (wire.ip ?? "203.0.113.7"), + origin_country: wire.source === "system" ? null : (wire.country ?? "DE"), + occurred_at: occurred, + recorded_at: occurred, + }) +} + +export const AUDIT_ENTRIES: ReadonlyArray = Arr.map( + [ + { + n: 1, + minutesAgo: 3, + action: "telemetry.sql_executed", + actor: "agent", + source: "mcp", + denied: "Only SELECT statements may run", + metadata: { + sql: "DELETE FROM traces WHERE ServiceName = 'checkout'", + context: "mcp.run_sql", + start_time: "2026-11-09 14:00:00", + end_time: "2026-11-09 15:00:00", + }, + ip: "198.51.100.23", + country: "US", + }, + { + n: 2, + minutesAgo: 6, + action: "mcp_tool.called", + actor: "agent", + source: "mcp", + metadata: { + tool: "search_traces", + surface: "mcp", + is_error: false, + params: '{"service":"checkout","status":"Error","limit":20}', + }, + ip: "198.51.100.23", + country: "US", + }, + { + n: 3, + minutesAgo: 9, + action: "dashboard.updated", + actor: "ada", + source: "dashboard", + resource: { type: "dashboard", prefix: PublicIdPrefixes.dashboard }, + changes: { + fields: ["name", "tags"], + before: { name: "Checkout", tags: ["prod"] }, + after: { name: "Checkout overview", tags: ["prod", "payments"] }, + }, + }, + { + n: 4, + minutesAgo: 14, + action: "telemetry.read", + actor: "ada", + source: "dashboard", + metadata: { + endpoint: "queryEngine.execute", + method: "POST", + path: "/api/query-engine/execute", + status: 200, + }, + }, + { + n: 5, + minutesAgo: 31, + action: "api_key.created", + actor: "grace", + source: "dashboard", + resource: { type: "api_key", prefix: PublicIdPrefixes.apiKey }, + metadata: { name: "CI deploy key", scopes: ["telemetry:write", "dashboards:read"] }, + ip: "192.0.2.44", + country: "GB", + }, + { + n: 6, + minutesAgo: 47, + action: "api.request", + actor: "key", + source: "api", + denied: "Missing scope: alerts:write", + metadata: { method: "POST", path: "/v2/alert_rules" }, + ip: "203.0.113.90", + country: "NL", + }, + { + n: 7, + minutesAgo: 62, + action: "member.role_changed", + actor: "grace", + source: "dashboard", + affected: LINUS, + changes: { fields: ["role"], before: { role: "org:member" }, after: { role: "org:admin" } }, + ip: "192.0.2.44", + country: "GB", + }, + { + n: 8, + minutesAgo: 95, + action: "alert_rule.created", + actor: "linus", + source: "dashboard", + resource: { type: "alert_rule", prefix: PublicIdPrefixes.alertRule }, + ip: "198.51.100.7", + country: "FI", + }, + { + n: 9, + minutesAgo: 140, + action: "session_replay.read", + actor: "grace", + source: "dashboard", + metadata: { + endpoint: "sessionReplays.retrieve", + method: "GET", + path: "/v2/session_replays/rep_1", + status: 200, + }, + ip: "192.0.2.44", + country: "GB", + }, + { + n: 10, + minutesAgo: 60 * 20, + action: "error_issue.regression", + actor: "system", + source: "system", + resource: { type: "error_issue", prefix: PublicIdPrefixes.errorIssue }, + }, + { + n: 11, + minutesAgo: 60 * 22, + action: "dashboard_share.created", + actor: "linus", + source: "dashboard", + resource: { type: "dashboard_share", prefix: PublicIdPrefixes.dashboardShare }, + ip: "198.51.100.7", + country: "FI", + }, + { + n: 12, + minutesAgo: 60 * 26, + action: "source.rolled", + actor: "ada", + source: "dashboard", + resource: { type: "source", prefix: PublicIdPrefixes.source }, + metadata: { platform: "opentelemetry" }, + }, + { + n: 13, + minutesAgo: 60 * 50, + action: "ingest_key.rolled", + actor: "ada", + source: "dashboard", + metadata: { key: "private" }, + }, + { + n: 14, + minutesAgo: 60 * 75, + action: "mcp_tool.called", + actor: "agent", + source: "chat_platform", + metadata: { + tool: "list_errors", + surface: "chat", + is_error: true, + params: '{"service":"payments"}', + connector: "slack", + workspace_id: "T024BE7LD", + external_user_id: "U012AB3CD", + display_name: "Grace Hopper", + }, + ip: "198.51.100.200", + country: "US", + }, + ] satisfies ReadonlyArray, + entry, +) diff --git a/apps/web/src/lab/audit-log-lab.ts b/apps/web/src/lab/audit-log-lab.ts new file mode 100644 index 0000000000..5a70c0c638 --- /dev/null +++ b/apps/web/src/lab/audit-log-lab.ts @@ -0,0 +1,181 @@ +import type { V2AuditLogEntry } from "@maple/domain/http/v2" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { Array as Arr, Effect, Option, Record as Rec, Result, Schema, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as AuditModel from "../page/settings/audit-log/model" +import * as AuditUpdate from "../page/settings/audit-log/update" +import * as AuditView from "../page/settings/audit-log/view" +import type { Resources } from "../platform/resources" +import { SettingsSearch } from "../route/search" +import { AUDIT_ENTRIES, AUDIT_MEMBERS, AUDIT_NOW } from "./audit-log-fixture" +import { keepLocal } from "./local-commands" + +// `/lab/audit-log`: the audit log tab over fixtures. Filters, the kind switch and the lookup all +// work: a navigation re-reads the fixtures through the page's own request. `?state=` picks a start. + +export const LabState = Schema.Literals([ + "changes", + "access", + "all", + "filtered", + "expanded", + "lookup-unknown", + "empty", + "error", + "loading", +]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "changes", + ), +})) + +type Search = typeof SettingsSearch.Type + +export const Model = Schema.Struct({ state: LabState, search: SettingsSearch, audit: AuditModel.Model }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotAuditMessage: { message: AuditModel.Message } }) +export type Message = typeof Message.Type + +type Return = Update.Return +type AuditCommand = Command.Command + +const TIME_ZONE = "Europe/Berlin" +const ACCESS_SUFFIXES = [".read", ".called", ".sql_executed", ".request"] +const isAccess = (action: string) => Arr.some(ACCESS_SUFFIXES, (suffix) => Str.endsWith(suffix)(action)) + +/** The fixtures the API would answer `request` with. */ +const matching = (request: AuditModel.PageRequest): ReadonlyArray => + Arr.filter( + AUDIT_ENTRIES, + (entry) => + (request.kind !== "changes" || !isAccess(entry.action) || entry.outcome === "denied") && + (request.kind !== "access" || isAccess(entry.action)) && + (request.actorType === undefined || entry.actor_type === request.actorType) && + (request.actorId === undefined || entry.actor_id === request.actorId) && + (request.outcome === undefined || entry.outcome === request.outcome) && + (request.resourceType === undefined || entry.resource_type === request.resourceType) && + (request.action === undefined || entry.action === request.action) && + (request.resourceId === undefined || entry.resource_id === request.resourceId) && + (request.affectedUserId === undefined || entry.affected_user === request.affectedUserId) && + (request.requestId === undefined || entry.request_id === request.requestId), + ) + +const searchFor = (state: LabState): Search => { + switch (state) { + case "access": + return { tab: "audit-log", kind: "access" } + case "all": + return { tab: "audit-log", kind: "all" } + case "filtered": + return { tab: "audit-log", kind: "all", actor: AUDIT_MEMBERS[1].value, resource_type: "api_key" } + default: + return { tab: "audit-log" } + } +} + +const decodeSearch = Schema.decodeUnknownOption(SettingsSearch) + +/** The search a page navigation lands on, read off its URL. */ +const searchOfUrl = (url: string): Search => + Option.getOrElse( + decodeSearch(Rec.fromEntries([...new URL(url, "https://lab.local").searchParams.entries()])), + (): Search => ({ tab: "audit-log" }), + ) + +/** Answers a page read the step asked for from the fixtures, as the API would. */ +const answer = (state: LabState, model: AuditModel.Model, commands: ReadonlyArray) => { + if (state === "loading" || !Arr.some(commands, (command) => command.name === "FetchAuditLogPage")) + return model + const result = + state === "error" + ? Result.fail(toLoadFailure("The warehouse did not answer in time")) + : Result.succeed({ + data: state === "empty" ? [] : matching(model.request), + has_more: false, + next_cursor: null, + }) + return AuditUpdate.update( + model, + AuditModel.Message.CompletedFetchPage({ request: model.request, resolvedSince: null, result }), + {}, + ).model +} + +/** The kit's own commands and the custom range's clock read; every API read is answered above. */ +const local = (commands: ReadonlyArray | undefined) => [ + ...keepLocal(commands), + ...Arr.filter(commands ?? [], (command) => command.name === "ResolveAuditCustomToday"), +] + +const lift = (commands: ReadonlyArray) => + Command.mapMessages(commands, (message) => Message.GotAuditMessage({ message })) + +export const init = (flags: Flags): Return => { + const search = searchFor(flags.state) + const entered = AuditUpdate.init(search) + const withMembers = AuditUpdate.update( + answer(flags.state, entered.model, entered.commands ?? []), + AuditModel.Message.CompletedFetchMembers({ members: AUDIT_MEMBERS }), + search, + ).model + const extra: ReadonlyArray = + flags.state === "expanded" + ? [AuditModel.Message.ToggledEntry({ id: AUDIT_ENTRIES[2]?.id ?? "" })] + : flags.state === "lookup-unknown" + ? [ + AuditModel.Message.ChangedLookup({ value: "checkout" }), + AuditModel.Message.SubmittedLookup(), + ] + : [] + const audit = Arr.reduce( + extra, + withMembers, + (model, message) => AuditUpdate.update(model, message, search).model, + ) + return { model: { state: flags.state, search, audit } } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotAuditMessage: ({ message: child }) => { + const step = AuditUpdate.update(model.audit, child, model.search) + const out = step.outMessage + if (out?._tag !== "RequestedNavigation") + return { + model: { ...model, audit: answer(model.state, step.model, step.commands ?? []) }, + commands: lift(local(step.commands)), + } + const search = searchOfUrl(out.url) + const moved = AuditUpdate.searchChanged(step.model, search) + return { + model: { ...model, search, audit: answer(model.state, moved.model, moved.commands ?? []) }, + commands: lift([...local(step.commands), ...local(moved.commands)]), + } + }, + }) + +export const breadcrumbs = [{ label: "Lab", href: "/lab" }, { label: "Audit log" }] as const + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("mx-auto w-full max-w-5xl p-6")], + [ + h.submodel({ + slotId: "lab-audit-log", + model: model.audit, + view: AuditView.view, + viewInputs: { search: model.search, nowMs: AUDIT_NOW, timeZone: TIME_ZONE, apiKeys: [] }, + toParentMessage: (message: AuditModel.Message) => Message.GotAuditMessage({ message }), + }), + ], + ) diff --git a/apps/web/src/lab/entries/audit-log.ts b/apps/web/src/lab/entries/audit-log.ts new file mode 100644 index 0000000000..2b2f77344a --- /dev/null +++ b/apps/web/src/lab/entries/audit-log.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../audit-log-lab" + +// `/lab/audit-log`: the settings audit log tab over fixtures (`?state=`). + +export const lab = defineShellLab("Audit log", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/audit-log", + breadcrumbs, +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index c338dbc73d..6e1a5f6cac 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -61,6 +61,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/audit-log", + title: "Audit log", + description: + "The settings audit log over fixtures: changes, data access, filters, an expanded entry, an unknown lookup, empty, failing and loading (`?state=`).", + kind: "lab", + session: "none", + }, { path: "/lab/slos", title: "SLOs", diff --git a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts index 1c27425258..84512e9897 100644 --- a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts @@ -2,6 +2,7 @@ import { Result, Array as Arr } from "effect" import { expect as expectView, given, role, scene, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import { PageOutMessage } from "../../contract" +import * as CustomRange from "../../kit/custom-range" import { Message } from "./model" import { init, revisit, searchChanged, update } from "./update" import { view } from "./view" @@ -22,7 +23,11 @@ describe("audit log tab", () => { const moved = searchChanged(entered, { outcome: "denied" }).model const stale = update( moved, - Message.CompletedFetchPage({ request: {}, resolvedSince: null, result: Result.succeed(page) }), + Message.CompletedFetchPage({ + request: init({}).model.request, + resolvedSince: null, + result: Result.succeed(page), + }), { outcome: "denied" }, ) expect(stale.model.view).toBeNull() @@ -88,9 +93,14 @@ describe("audit log tab", () => { }) test("Load more pins the ceiling to the newest entry and asks for the next cursor", () => { + const entered = init({}).model const loaded = update( - init({}).model, - Message.CompletedFetchPage({ request: {}, resolvedSince: null, result: Result.succeed(page) }), + entered, + Message.CompletedFetchPage({ + request: entered.request, + resolvedSince: null, + result: Result.succeed(page), + }), {}, ).model expect(update(loaded, Message.ClickedLoadMore(), {}).commands?.[0]).toMatchObject({ @@ -99,9 +109,14 @@ describe("audit log tab", () => { }) test("a revisit reads the first page quietly and holds Load more until it lands", () => { + const entered = init({}).model const loaded = update( - init({}).model, - Message.CompletedFetchPage({ request: {}, resolvedSince: null, result: Result.succeed(page) }), + entered, + Message.CompletedFetchPage({ + request: entered.request, + resolvedSince: null, + result: Result.succeed(page), + }), {}, ).model const back = revisit(loaded, {}) @@ -113,4 +128,108 @@ describe("audit log tab", () => { test("a revisit while the same first page is in flight does not read it twice", () => { expect(revisit(init({}).model, {}).commands ?? []).toEqual([]) }) + + test("the list opens on changes, and the kind switch writes access or all to the URL", () => { + const entered = init({ tab: "audit-log" }) + expect(entered.commands?.[0]).toMatchObject({ args: { request: { kind: "changes" } } }) + expect(init({ kind: "all" }).model.request.kind).toBeUndefined() + const step = update(entered.model, Message.ClickedKind({ value: "access" }), { tab: "audit-log" }) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&kind=access", + isReplace: true, + }), + ) + const back = update(entered.model, Message.ClickedKind({ value: "changes" }), { + tab: "audit-log", + kind: "access", + }) + expect(back.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ url: "/settings?tab=audit-log", isReplace: true }), + ) + }) + + test("a pasted id becomes the filter it stands for; anything else says what the lookup takes", () => { + const search = { tab: "audit-log" } as const + const typed = update( + init(search).model, + Message.ChangedLookup({ value: " key_aXwpxqBkqtYwtBtmsGbR41 " }), + search, + ) + expect(update(typed.model, Message.SubmittedLookup(), search).outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&actor=key_aXwpxqBkqtYwtBtmsGbR41", + isReplace: true, + }), + ) + const ray = update( + init(search).model, + Message.ChangedLookup({ value: "8f2c1a9d4b7e3f60-FRA" }), + search, + ) + expect(update(ray.model, Message.SubmittedLookup(), search).outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&request=8f2c1a9d4b7e3f60-FRA", + isReplace: true, + }), + ) + const junk = update(init(search).model, Message.ChangedLookup({ value: "hello world" }), search) + const refused = update(junk.model, Message.SubmittedLookup(), search) + expect(refused.outMessage).toBeUndefined() + scene( + { + update: (model, message) => update(model, message, search), + view: withViewInputs(view, { search, nowMs: 0, timeZone: "UTC", apiKeys: [] })(), + }, + given(refused.model), + expectView(role("status")).toHaveText(/Not something the log filters by/), + ) + }) + + test("removing the resource type drops the action that belongs to it", () => { + const search = { + tab: "audit-log", + resource_type: "dashboard", + action: "dashboard.updated", + outcome: "denied", + } as const + expect( + update(init(search).model, Message.RemovedFilter({ key: "resourceType" }), search).outMessage, + ).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&outcome=denied", + isReplace: true, + }), + ) + }) + + test("an applied custom range lands in the URL as UTC instants", () => { + const search = { tab: "audit-log" } as const + const opened = update( + init(search).model, + Message.ResolvedCustomToday({ + timeZone: "UTC", + today: { year: 2026, month: 11, day: 9 }, + lastDay: { year: 2026, month: 11, day: 9 }, + }), + search, + ).model + const start: ReturnType = { model: opened } + const picked = Arr.reduce( + [ + CustomRange.Message.ClickedDay({ date: { year: 2026, month: 11, day: 2 } }), + CustomRange.Message.ClickedDay({ date: { year: 2026, month: 11, day: 4 } }), + CustomRange.Message.ClickedApply(), + ], + start, + (step, message) => update(step.model, Message.GotCustomRangeMessage({ message }), search), + ) + expect(picked.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&since=2026-11-02T00%3A00%3A00.000Z&until=2026-11-04T23%3A59%3A00.000Z", + isReplace: true, + }), + ) + expect(picked.model.customRange).toBeNull() + }) }) diff --git a/apps/web/src/page/settings/audit-log/model.ts b/apps/web/src/page/settings/audit-log/model.ts index 17662b1bfd..83d3a89d37 100644 --- a/apps/web/src/page/settings/audit-log/model.ts +++ b/apps/web/src/page/settings/audit-log/model.ts @@ -6,6 +6,7 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as CopyButton from "@maple/views/ui/copy-button" import * as Popover from "@maple/views/ui/popover" import * as Select from "@maple/views/ui/select" +import * as CustomRange from "../../kit/custom-range" // `AuditLogSection`. Filters live in the `/settings` search; the page passes them in and a change comes back // as a navigation. @@ -16,6 +17,13 @@ export const PageRequest = Schema.Struct({ actorType: Schema.optional(AuditActorType), actorId: Schema.optional(Schema.String), outcome: Schema.optional(AuditOutcome), + /** `changes` or `access`; absent lists both. */ + kind: Schema.optional(Schema.Literals(["changes", "access"])), + resourceType: Schema.optional(Schema.String), + action: Schema.optional(Schema.String), + resourceId: Schema.optional(Schema.String), + affectedUserId: Schema.optional(Schema.String), + requestId: Schema.optional(Schema.String), since: Schema.optional(Schema.String), until: Schema.optional(Schema.String), /** A rolling preset: part of the key, resolved to `since` when the page is fetched. */ @@ -60,7 +68,16 @@ export const Model = Schema.Struct({ expanded: Schema.Array(Schema.String), copies: Schema.Record(Schema.String, CopyButton.Model), personSelect: Select.Model, + resourceTypeSelect: Select.Model, + actionSelect: Select.Model, + affectedSelect: Select.Model, dateRange: Popover.Model, + /** The date popover's custom tab, open once the clock gave today. */ + customRange: Schema.NullOr(CustomRange.Model), + /** The lookup field's draft: a pasted id or action, applied on Enter. */ + lookup: Schema.String, + /** The last Enter matched no id shape or action. */ + isLookupUnknown: Schema.Boolean, }) export type Model = typeof Model.Type @@ -83,5 +100,21 @@ export const Message = defineMessageUnion({ GotDateRangeMessage: { message: Popover.Message }, ClickedRangePreset: { value: Schema.String }, ClickedClearFilters: {}, + ClickedKind: { value: Schema.String }, + GotResourceTypeSelectMessage: { message: Select.Message }, + GotActionSelectMessage: { message: Select.Message }, + GotAffectedSelectMessage: { message: Select.Message }, + ChangedLookup: { value: Schema.String }, + SubmittedLookup: {}, + ClearedLookup: {}, + /** A filter chip's x: the key of the filter it removes. */ + RemovedFilter: { key: Schema.String }, + ClickedCustomRange: { timeZone: Schema.String }, + ResolvedCustomToday: { + timeZone: Schema.String, + today: CustomRange.CalendarDate, + lastDay: CustomRange.CalendarDate, + }, + GotCustomRangeMessage: { message: CustomRange.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/settings/audit-log/update.ts b/apps/web/src/page/settings/audit-log/update.ts index 05dd342dfe..9748a3fde0 100644 --- a/apps/web/src/page/settings/audit-log/update.ts +++ b/apps/web/src/page/settings/audit-log/update.ts @@ -6,10 +6,14 @@ import { AUDIT_RANGE_PRESETS, type AuditDateRange, type AuditLogFilters, + auditLogKindOf, auditLogSearchFrom, parseAuditLogSearch, + parseAuditLookup, } from "../../../components/settings/audit-log-filters" -import { Clock, Effect, Option, Result, Schema, Array as Arr } from "effect" +import { formatWarehouseDateTime, parseWarehouseDateTime } from "@maple/query-engine/datetime" +import * as CustomRange from "../../kit/custom-range" +import { Clock, DateTime, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import { isClerkAuthEnabled } from "../../../lib/services/common/auth-mode" @@ -28,6 +32,8 @@ type Return = PageReturn type SettingsSearch = typeof SettingsSearchSchema.Type export const ANYONE = "anyone" +/** The "no filter" value of the resource type, action and affected user selects. */ +export const ANY = "any" const asActorType = Schema.decodeUnknownOption(AuditActorType) const asOutcome = Schema.decodeUnknownOption(AuditOutcome) @@ -35,7 +41,14 @@ const asOutcome = Schema.decodeUnknownOption(AuditOutcome) /** The filter input `AuditLogSection` derives from the URL; a rolling preset resolves in the Command. */ export const baseRequestOf = (filters: AuditLogFilters): PageRequest => { const range = filters.range + const kind = auditLogKindOf(filters) return { + ...(kind !== "all" && { kind }), + ...(filters.resourceType !== undefined && { resourceType: filters.resourceType }), + ...(filters.action !== undefined && { action: filters.action }), + ...(filters.resourceId !== undefined && { resourceId: filters.resourceId }), + ...(filters.affectedUserId !== undefined && { affectedUserId: filters.affectedUserId }), + ...(filters.requestId !== undefined && { requestId: filters.requestId }), ...(filters.actorType !== undefined && { actorType: filters.actorType }), ...(filters.actorId !== undefined && { actorId: filters.actorId }), ...(filters.outcome !== undefined && { outcome: filters.outcome }), @@ -53,14 +66,23 @@ const presetMsOf = (range: AuditDateRange): number | null => ) : null -const sameRequest = (a: PageRequest, b: PageRequest) => - a.cursor === b.cursor && - a.actorType === b.actorType && - a.actorId === b.actorId && - a.outcome === b.outcome && - a.since === b.since && - a.until === b.until && - a.preset === b.preset +const REQUEST_KEYS = [ + "cursor", + "kind", + "actorType", + "actorId", + "outcome", + "resourceType", + "action", + "resourceId", + "affectedUserId", + "requestId", + "since", + "until", + "preset", +] as const satisfies ReadonlyArray + +const sameRequest = (a: PageRequest, b: PageRequest) => Arr.every(REQUEST_KEYS, (key) => a[key] === b[key]) /** `auditLogPageAtom`: one page, newest first; a preset window resolves against the clock now. */ const FetchAuditPage = Command.define("FetchAuditLogPage", { @@ -87,6 +109,12 @@ const fetchPage = (request: PageRequest, presetSince: string | null) => query: { limit: AUDIT_LOG_PAGE_LIMIT, ...(request.cursor !== undefined && { cursor: request.cursor }), + kind: request.kind ?? "all", + ...(request.resourceType !== undefined && { resource_type: request.resourceType }), + ...(request.action !== undefined && { action: request.action }), + ...(request.resourceId !== undefined && { resource_id: request.resourceId }), + ...(request.affectedUserId !== undefined && { affected_user: request.affectedUserId }), + ...(request.requestId !== undefined && { request_id: request.requestId }), ...(request.actorType !== undefined && { actor_type: request.actorType }), ...(request.actorId !== undefined && { actor_id: request.actorId }), ...(request.outcome !== undefined && { outcome: request.outcome }), @@ -155,7 +183,13 @@ export const init = (search: SettingsSearch): Return => { expanded: [], copies: {}, personSelect: Select.init("audit-person", { value: ANYONE, selectedIndex: 0 }), + resourceTypeSelect: Select.init("audit-resource-type", { value: ANY, selectedIndex: 0 }), + actionSelect: Select.init("audit-action", { value: ANY, selectedIndex: 0 }), + affectedSelect: Select.init("audit-affected", { value: ANY, selectedIndex: 0 }), dateRange: Popover.init("audit-date-range", { align: "start" }), + customRange: null, + lookup: "", + isLookupUnknown: false, }, baseRequestOf(parseAuditLogSearch(search)), search, @@ -210,6 +244,68 @@ export const revisit = (model: Model, search: SettingsSearch): Return => { } } +/** "Custom range…" opens on the list's own window once the clock gave today. */ +const ResolveAuditCustomToday = Command.define("ResolveAuditCustomToday", { + args: { timeZone: Schema.String }, + messages: [Message.ResolvedCustomToday], + execute: ({ timeZone }) => + Effect.map(CustomRange.readToday(timeZone), (days) => + Message.ResolvedCustomToday({ timeZone, ...days }), + ), +}) + +const isoToWarehouse = (iso: string | undefined): string | null => + iso === undefined ? null : formatWarehouseDateTime(DateTime.toEpochMillis(DateTime.makeUnsafe(iso))) +const warehouseToIso = (value: string): string => new Date(parseWarehouseDateTime(value)).toISOString() + +type SelectField = "personSelect" | "resourceTypeSelect" | "actionSelect" | "affectedSelect" + +/** One select's step; `onChange` turns a picked value into the filters to navigate to. */ +const selectStep = ( + model: Model, + search: SettingsSearch, + field: SelectField, + child: Select.Message, + wrap: (message: Select.Message) => Message, + onChange: (value: string) => AuditLogFilters, +): Return => { + const step = Select.update(model[field], child) + const lifted: Return = { + model: { ...model, [field]: step.model }, + commands: Command.mapMessages(step.commands ?? [], wrap), + } + if (step.outMessage === undefined) return lifted + return { + ...lifted, + outMessage: navigateFilters(lifted.model, search, onChange(step.outMessage.value)).outMessage, + } +} + +const orAny = (value: string) => (value === ANY ? undefined : value) + +/** The filters without one of them: what a chip's x navigates to. */ +const withoutFilter = (filters: AuditLogFilters, key: string): AuditLogFilters => { + switch (key) { + case "range": + return { ...filters, range: { kind: "all" } } + case "resourceType": + // An action belongs to its resource type; it goes with it. + return { ...filters, resourceType: undefined, action: undefined } + case "actorId": + case "actorType": + case "outcome": + case "action": + case "resourceId": + case "affectedUserId": + case "requestId": + return { ...filters, [key]: undefined } + default: + return filters + } +} + +const liftCustom = (model: Model, customRange: CustomRange.Model | null): Model => ({ ...model, customRange }) + export const update = (model: Model, message: Message, search: SettingsSearch): Return => { const filters = parseAuditLogSearch(search) return Message.match(message, { @@ -273,25 +369,109 @@ export const update = (model: Model, message: Message, search: SettingsSearch): }), ClickedOutcome: ({ value }) => navigateFilters(model, search, { ...filters, outcome: Option.getOrUndefined(asOutcome(value)) }), - GotPersonSelectMessage: ({ message: child }) => { - const step = Select.update(model.personSelect, child) - const lifted: Return = { - model: { ...model, personSelect: step.model }, - commands: Command.mapMessages(step.commands ?? [], (inner) => - Message.GotPersonSelectMessage({ message: inner }), - ), - } - if (step.outMessage === undefined) return lifted - const actorId = step.outMessage.value === ANYONE ? undefined : step.outMessage.value + GotPersonSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "personSelect", + child, + (inner) => Message.GotPersonSelectMessage({ message: inner }), + (value) => ({ ...filters, actorId: value === ANYONE ? undefined : value }), + ), + GotResourceTypeSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "resourceTypeSelect", + child, + (inner) => Message.GotResourceTypeSelectMessage({ message: inner }), + (value) => ({ ...filters, resourceType: orAny(value), action: undefined }), + ), + GotActionSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "actionSelect", + child, + (inner) => Message.GotActionSelectMessage({ message: inner }), + (value) => ({ ...filters, action: orAny(value) }), + ), + GotAffectedSelectMessage: ({ message: child }) => + selectStep( + model, + search, + "affectedSelect", + child, + (inner) => Message.GotAffectedSelectMessage({ message: inner }), + (value) => ({ ...filters, affectedUserId: orAny(value) }), + ), + ClickedKind: ({ value }) => + navigateFilters(model, search, { + ...filters, + kind: value === "access" || value === "all" ? value : undefined, + }), + ChangedLookup: ({ value }) => ({ model: { ...model, lookup: value, isLookupUnknown: false } }), + ClearedLookup: () => ({ model: { ...model, lookup: "", isLookupUnknown: false } }), + SubmittedLookup: () => + Option.match(parseAuditLookup(model.lookup), { + onNone: () => ({ + model: { ...model, isLookupUnknown: Str.isNonEmpty(Str.trim(model.lookup)) }, + }), + onSome: (lookup) => + navigateFilters({ ...model, lookup: "", isLookupUnknown: false }, search, { + ...filters, + [lookup.key]: lookup.value, + // A looked-up action names its resource type; a stale one would hide it. + ...(lookup.key === "action" && { resourceType: undefined }), + }), + }), + RemovedFilter: ({ key }) => navigateFilters(model, search, withoutFilter(filters, key)), + ClickedCustomRange: ({ timeZone }) => ({ model, commands: [ResolveAuditCustomToday({ timeZone })] }), + ResolvedCustomToday: ({ timeZone, today, lastDay }) => { + const range = filters.range return { - ...lifted, - outMessage: navigateFilters(lifted.model, search, { ...filters, actorId }).outMessage, + model: liftCustom( + model, + CustomRange.init({ + startTime: range.kind === "custom" ? isoToWarehouse(range.since) : null, + endTime: range.kind === "custom" ? isoToWarehouse(range.until) : null, + timeZone, + today, + lastDay, + }), + ), } }, + GotCustomRangeMessage: ({ message: child }) => { + if (model.customRange === null) return { model } + const step = CustomRange.update(model.customRange, child) + const out = step.outMessage + if (out === undefined) return { model: liftCustom(model, step.model) } + return CustomRange.OutMessage.match(out, { + CancelledRange: () => ({ model: liftCustom(model, null) }), + AppliedRange: ({ startTime, endTime }) => { + const closed = Popover.close(model.dateRange) + return { + ...navigateFilters({ ...liftCustom(model, null), dateRange: closed.model }, search, { + ...filters, + range: { + kind: "custom", + since: warehouseToIso(startTime), + until: warehouseToIso(endTime), + }, + }), + commands: Command.mapMessages(closed.commands ?? [], (inner) => + Message.GotDateRangeMessage({ message: inner }), + ), + } + }, + }) + }, GotDateRangeMessage: ({ message: child }) => { const step = Popover.update(model.dateRange, child) return { - model: { ...model, dateRange: step.model }, + // Closing the popover drops a half-picked custom range. + model: { ...model, dateRange: step.model, ...(!step.model.isOpen && { customRange: null }) }, commands: Command.mapMessages(step.commands ?? [], (inner) => Message.GotDateRangeMessage({ message: inner }), ), @@ -313,6 +493,7 @@ export const update = (model: Model, message: Message, search: SettingsSearch): ), } }, - ClickedClearFilters: () => navigateFilters(model, search, { range: { kind: "all" } }), + ClickedClearFilters: () => + navigateFilters(model, search, { range: { kind: "all" }, kind: filters.kind }), }) } diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 95d5e464fc..27d6b39798 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -1,4 +1,4 @@ -import { auditActionLabel } from "@maple/domain/audit-actions" +import { auditActionLabel, AuditResourceLabels, AuditResources } from "@maple/domain/audit-actions" import type { AuditActorType, AuditLogSource } from "@maple/domain/http" import { encodePublicId, PublicIdPrefixes, type V2ApiKey, type V2AuditLogEntry } from "@maple/domain/http/v2" import { EMPTY_VALUE } from "@maple/ui/lib/format" @@ -8,9 +8,15 @@ import { cn } from "@maple/ui/lib/utils" import { AUDIT_RANGE_PRESETS, type AuditDateRange, + type AuditLogFilters, + auditLogKindOf, hasAuditLogFilters, parseAuditLogSearch, } from "../../../components/settings/audit-log-filters" +import { compactSearchInput } from "@maple/views/ui/compact-search-input" +import { removableChip } from "@maple/views/ui/removable-chip" +import type { Message as SelectMessage } from "@maple/views/ui/select" +import * as CustomRangeView from "../../kit/custom-range-view" import { auditResourceDisplay } from "../../../components/settings/audit-log-resource" import { formatTimestampInTimezone } from "@maple/web-data/lib/timezone-format" import { Submodel } from "foldkit" @@ -35,11 +41,11 @@ import { relativeTime } from "../../kit/relative-time" import { filteredEmpty } from "@maple/views/ui/signal-empty-state" import { settingsSection } from "../kit/section" import { Message, type Model } from "./model" -import { ANYONE } from "./update" +import { ANY, ANYONE } from "./update" import { auditDetail, resourceHref } from "./view-detail" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, hstackClass, vstack, vstackClass } from "@maple/views/ui/stack" -import { Array as Arr, String as Str, Option } from "effect" +import { Array as Arr, String as Str, Option, Order, Record as Rec } from "effect" import { textLink } from "@maple/views/ui/text-link" type H = HtmlBuilder @@ -58,6 +64,11 @@ const ACTOR_FILTERS = [ { value: "agent", label: "Agents" }, { value: "system", label: "System" }, ] as const +const KIND_OPTIONS = [ + { value: "changes", label: "Changes" }, + { value: "access", label: "Data access" }, + { value: "all", label: "All" }, +] as const const OUTCOME_FILTERS = [ { value: "all", label: "All" }, { value: "allowed", label: "Allowed" }, @@ -122,6 +133,9 @@ const rangeLabel = (range: AuditDateRange, timeZone: string): string => { // TOOLBAR +const RANGE_OPTION_CLASS = + "flex h-7 items-center px-3 text-left text-xs hover:bg-surface-sunken active:bg-surface-sunken" + const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): Html => { const label = rangeLabel(range, timeZone) const activeKey = range.kind === "all" ? "all" : range.kind === "preset" ? range.preset : null @@ -129,13 +143,18 @@ const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): { key: "all", label: "All time" }, ...Arr.map(AUDIT_RANGE_PRESETS, (preset) => ({ key: preset.value, label: preset.label })), ] + const optionClass = (isActive: boolean) => + cn( + RANGE_OPTION_CLASS, + isActive ? typographyClass({ tone: "foreground", weight: "medium" }) : "text-foreground-soft", + ) return h.submodel({ slotId: "audit-date-range", model: model.dateRange, view: Popover.view, toParentMessage: (message) => Message.GotDateRangeMessage({ message }), viewInputs: { - className: "w-48 p-0", + className: model.customRange === null ? "w-48 p-0" : "w-auto p-0", toView: ({ trigger, withTrigger }) => h.div( [h.Class("contents")], @@ -153,45 +172,109 @@ const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): ), ), ), - // TODO(settings-audit): "Custom range…" opens `CustomRangePicker`, not ported (shared with the - // time-range picker's custom tab); it is drawn without its picker. toContent: () => [ - vstack(h, { className: "py-1.5" }, [ - ...Arr.map(options, (option) => - pressable( - h, - { - onClick: Message.ClickedRangePreset({ value: option.key }), - className: cn( - "flex h-7 items-center px-3 text-left text-xs hover:bg-surface-sunken active:bg-surface-sunken", - option.key === activeKey - ? typographyClass({ tone: "foreground", weight: "medium" }) - : "text-foreground-soft", + model.customRange !== null + ? h.submodel({ + slotId: "audit-custom-range", + model: model.customRange, + view: CustomRangeView.view, + viewInputs: {}, + toParentMessage: (message) => Message.GotCustomRangeMessage({ message }), + }) + : vstack(h, { className: "py-1.5" }, [ + ...Arr.map(options, (option) => + pressable( + h, + { + onClick: Message.ClickedRangePreset({ value: option.key }), + className: optionClass(option.key === activeKey), + attributes: [h.Key(option.key)], + }, + [option.label], ), - attributes: [h.Key(option.key)], - }, - [option.label], - ), - ), - separator(h, "horizontal", "mx-3 my-1 data-[orientation=horizontal]:w-auto"), - pressable( - h, - { - className: cn( - "flex h-7 items-center px-3 text-left text-xs hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", - range.kind === "custom" - ? typographyClass({ tone: "foreground", weight: "medium" }) - : "text-muted-foreground", ), - }, - ["Custom range…"], - ), - ]), + separator(h, "horizontal", "mx-3 my-1 data-[orientation=horizontal]:w-auto"), + pressable( + h, + { + onClick: Message.ClickedCustomRange({ timeZone }), + className: optionClass(range.kind === "custom"), + }, + ["Custom range…"], + ), + ]), ], }, }) } +/** A filter select with an "any" first item and the picked label shown in the trigger. */ +const filterSelect = ( + h: H, + options: Readonly<{ + slotId: string + model: Model["resourceTypeSelect"] + value: string | undefined + anyLabel: string + ariaLabel: string + items: ReadonlyArray + toMessage: (message: SelectMessage) => Message + }>, +): Html => { + const label = + options.value === undefined + ? options.anyLabel + : Option.match( + Arr.findFirst(options.items, (item) => item.value === options.value), + { onNone: () => options.value ?? options.anyLabel, onSome: (item) => item.label }, + ) + return h.submodel({ + slotId: options.slotId, + model: { ...options.model, value: options.value ?? ANY }, + view: SelectView.view, + toParentMessage: options.toMessage, + viewInputs: { + entries: [ + { _tag: "Item", item: { value: ANY, label: options.anyLabel } }, + ...Arr.map(options.items, (item): SelectView.SelectEntry => ({ _tag: "Item", item })), + ], + ariaLabel: options.ariaLabel, + size: "sm", + className: "w-auto min-w-36 max-w-64", + renderValue: () => [ + typography( + h, + { truncate: true, tone: options.value === undefined ? "muted" : "foreground" }, + [label], + ), + ], + toView: (parts) => h.div([h.Class("contents")], parts), + }, + }) +} + +const byLabel = Order.mapInput(Order.String, (item: SelectView.SelectOption) => item.label) + +const RESOURCE_TYPE_ITEMS: ReadonlyArray = Arr.sort( + Arr.map(Rec.toEntries(AuditResourceLabels), ([value, label]) => ({ value, label })), + byLabel, +) + +const actionItems = (resourceType: string): ReadonlyArray => + Option.match( + Arr.findFirst(Rec.toEntries(AuditResources), ([key]) => key === resourceType), + { + onNone: () => [], + onSome: ([, resource]) => { + const verbs: ReadonlyArray = resource.verbs + return Arr.map(verbs, (verb) => { + const action = `${resourceType}.${verb}` + return { value: action, label: auditActionLabel(action) ?? action } + }) + }, + }, + ) + const personFilter = ( h: H, model: Model, @@ -437,14 +520,75 @@ const listBody = (h: H, model: Model, inputs: ViewInputs, isFiltered: boolean): ) } +/** Filters that have no control of their own, shown as removable chips (a looked-up id, an action). */ +const filterChips = (h: H, model: Model, filters: AuditLogFilters): ReadonlyArray => { + const memberName = (userId: string) => + Option.match( + Arr.findFirst(model.members, (member) => member.value === userId), + { onNone: () => userId, onSome: (member) => member.label }, + ) + const chips: ReadonlyArray<{ key: string; label: string } | null> = [ + filters.resourceId === undefined + ? null + : { key: "resourceId", label: `Resource ${filters.resourceId}` }, + filters.requestId === undefined ? null : { key: "requestId", label: `Request ${filters.requestId}` }, + // With a resource type picked, the action has its own select. + filters.action === undefined || filters.resourceType !== undefined + ? null + : { key: "action", label: auditActionLabel(filters.action) ?? filters.action }, + filters.affectedUserId === undefined || model.members.length > 0 + ? null + : { key: "affectedUserId", label: `Affected ${memberName(filters.affectedUserId)}` }, + ] + return Arr.flatMap(chips, (chip) => + chip === null + ? [] + : [ + removableChip(h, { + label: chip.label, + onRemove: Message.RemovedFilter({ key: chip.key }), + attributes: [h.Key(chip.key)], + }), + ], + ) +} + +const lookupField = (h: H, model: Model): ReadonlyArray => [ + compactSearchInput(h, { + value: model.lookup, + placeholder: "Paste an ID or action, then Enter", + ariaLabel: "Look up an ID or action", + onInput: (value) => Message.ChangedLookup({ value }), + onEnter: Message.SubmittedLookup(), + onClear: Message.ClearedLookup(), + clearLabel: "Clear lookup", + className: "h-8 w-full @lg:w-72", + }), + ...(model.isLookupUnknown + ? [ + typography(h, { size: "2xs", tone: "muted", attributes: [h.Role("status")] }, [ + "Not something the log filters by. Paste a user, API key, agent, resource or request ID, or an action like dashboard.updated.", + ]), + ] + : []), +] + +const KIND_DESCRIPTIONS = { + changes: "Changes and refused attempts. Data reads are under Data access.", + access: "Reads of telemetry and replays, MCP tool calls, SQL statements and API requests.", + all: "Every entry, changes and data access together.", +} as const + export const description = "Every change, refused attempt and data read from the dashboard, API, MCP and chat. Select an entry for its full record." export const view = Submodel.defineView((model, inputs, h) => { const filters = parseAuditLogSearch(inputs.search) + const kind = auditLogKindOf(filters) const isFailed = model.failure !== null const isWaiting = model.isWaiting && !model.isQuiet const view = model.view + const chips = filterChips(h, model, filters) return settingsSection( h, { @@ -454,10 +598,20 @@ export const view = Submodel.defineView((model, inpu framed: false, }, [ - vstack(h, { gap: "2" }, [ - hstack(h, { gap: "2", wrap: true }, [ - dateRange(h, model, filters.range, inputs.timeZone), - personFilter(h, model, filters.actorId, inputs.apiKeys), + vstack(h, { gap: "3", className: "@container" }, [ + hstack(h, { gap: "3", wrap: true, align: "center", justify: "between" }, [ + hstack(h, { gap: "3", wrap: true, align: "center" }, [ + segmentedSelect(h, { + size: "sm", + ariaLabel: "Show", + value: kind, + onChange: (value) => Message.ClickedKind({ value }), + options: KIND_OPTIONS, + }), + typography(h, { size: "xs", tone: "muted", className: "hidden @2xl:block" }, [ + KIND_DESCRIPTIONS[kind], + ]), + ]), // The labelled Reload every page header uses, beside the range it re-reads. refreshButton(h, { label: "Reload", @@ -465,22 +619,67 @@ export const view = Submodel.defineView((model, inpu onRefresh: Message.ClickedRefresh(), }), ]), - hstack(h, { gap: "3", wrap: true }, [ - segmentedSelect(h, { - size: "sm", - ariaLabel: "Actor type", - value: filters.actorType ?? "all", - onChange: (value) => Message.ClickedActorType({ value }), - options: ACTOR_FILTERS, - }), - segmentedSelect(h, { - size: "sm", - ariaLabel: "Outcome", - value: filters.outcome ?? "all", - onChange: (value) => Message.ClickedOutcome({ value }), - options: OUTCOME_FILTERS, + hstack(h, { gap: "2", wrap: true, align: "center" }, [ + dateRange(h, model, filters.range, inputs.timeZone), + personFilter(h, model, filters.actorId, inputs.apiKeys), + filterSelect(h, { + slotId: "audit-resource-type", + model: model.resourceTypeSelect, + value: filters.resourceType, + anyLabel: "Any resource", + ariaLabel: "Resource type", + items: RESOURCE_TYPE_ITEMS, + toMessage: (message) => Message.GotResourceTypeSelectMessage({ message }), }), + ...(filters.resourceType === undefined + ? [] + : [ + filterSelect(h, { + slotId: "audit-action", + model: model.actionSelect, + value: filters.action, + anyLabel: "Any action", + ariaLabel: "Action", + items: actionItems(filters.resourceType), + toMessage: (message) => Message.GotActionSelectMessage({ message }), + }), + ]), + ...(model.members.length === 0 + ? [] + : [ + filterSelect(h, { + slotId: "audit-affected", + model: model.affectedSelect, + value: filters.affectedUserId, + anyLabel: "Any affected user", + ariaLabel: "Affected user", + items: model.members, + toMessage: (message) => Message.GotAffectedSelectMessage({ message }), + }), + ]), + ]), + hstack(h, { gap: "3", wrap: true, align: "center", justify: "between" }, [ + hstack(h, { gap: "3", wrap: true }, [ + segmentedSelect(h, { + size: "sm", + ariaLabel: "Actor type", + // Five options outgrow a phone; the group scrolls instead of the page. + className: "max-w-full overflow-x-auto", + value: filters.actorType ?? "all", + onChange: (value) => Message.ClickedActorType({ value }), + options: ACTOR_FILTERS, + }), + segmentedSelect(h, { + size: "sm", + ariaLabel: "Outcome", + value: filters.outcome ?? "all", + onChange: (value) => Message.ClickedOutcome({ value }), + options: OUTCOME_FILTERS, + }), + ]), + vstack(h, { gap: "1", className: "w-full @lg:w-auto" }, lookupField(h, model)), ]), + ...(chips.length === 0 ? [] : [hstack(h, { gap: "1.5", wrap: true }, chips)]), ]), panel( h, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 84b89168c0..a192475a6d 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -317,6 +317,13 @@ export const SettingsSearch = Schema.Struct({ actor: Schema.optional(Schema.String), actor_type: Schema.optional(Schema.String), outcome: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), + resource_type: Schema.optional(Schema.String), + action: Schema.optional(Schema.String), + resource: Schema.optional(Schema.String), + affected: Schema.optional(Schema.String), + request: Schema.optional(Schema.String), + entry: Schema.optional(Schema.String), }) export const SignInSearch = Schema.Struct({ diff --git a/packages/backend/src/services/audit/AuditLogService.test.ts b/packages/backend/src/services/audit/AuditLogService.test.ts index 576d8727a0..0f9702014c 100644 --- a/packages/backend/src/services/audit/AuditLogService.test.ts +++ b/packages/backend/src/services/audit/AuditLogService.test.ts @@ -481,6 +481,65 @@ describe("AuditLogService.layerMemory", () => { }).pipe(Effect.provide(AuditLogService.layerMemory)), ) + it.effect("the changes kind keeps denied access; the access kind lists only access", () => + Effect.gen(function* () { + const audit = yield* AuditLogService + yield* audit.record({ + orgId: ORG, + actor: { type: "user", userId: USER }, + source: "dashboard", + action: "dashboard.created", + resourceId: DASHBOARD_ID, + }) + yield* TestClock.adjust("1 second") + yield* audit.record({ + orgId: ORG, + actor: { type: "agent" }, + source: "mcp", + action: "telemetry.read", + }) + yield* TestClock.adjust("1 second") + yield* audit.record({ + orgId: ORG, + actor: { type: "agent" }, + source: "mcp", + action: "telemetry.sql_executed", + outcome: "denied", + denialReason: "write statement", + }) + const actions = (rows: ReadonlyArray<{ readonly action: string }>) => + Arr.map(rows, (row) => row.action) + expect(actions(yield* audit.list(ORG, { actionKind: "changes", limit: 10, offset: 0 }))).toEqual([ + "telemetry.sql_executed", + "dashboard.created", + ]) + expect(actions(yield* audit.list(ORG, { actionKind: "access", limit: 10, offset: 0 }))).toEqual([ + "telemetry.sql_executed", + "telemetry.read", + ]) + }).pipe(Effect.provide(AuditLogService.layerMemory)), + ) + + it.effect("compiles the changes kind to an exclusion that keeps denied entries", () => + Effect.gen(function* () { + const warehouse = recordingWarehouse() + yield* Effect.gen(function* () { + const audit = yield* AuditLogService + yield* audit.list(ORG, { actionKind: "changes", limit: 5, offset: 0 }) + yield* audit.list(ORG, { actionKind: "access", limit: 5, offset: 0 }) + }).pipe( + Effect.provide( + Layer.effect(AuditLogService, AuditLogService.make).pipe(Layer.provide(warehouse.layer)), + ), + ) + const [changes, access] = warehouse.sql + expect(changes).toContain("NOT ((endsWith(audit_log.Action, '.read')") + expect(changes).toContain("OR audit_log.Outcome = 'denied')") + expect(access).toContain("endsWith(audit_log.Action, '.sql_executed')") + expect(access).not.toContain("NOT (endsWith(") + }), + ) + describe("recordHttpAudit attribution", () => { const tenant = new CurrentTenant.TenantSchema({ orgId: ORG, diff --git a/packages/backend/src/services/audit/AuditLogService.ts b/packages/backend/src/services/audit/AuditLogService.ts index 4b9fb69a0c..8afac5f199 100644 --- a/packages/backend/src/services/audit/AuditLogService.ts +++ b/packages/backend/src/services/audit/AuditLogService.ts @@ -93,6 +93,11 @@ export interface AuditLogListFilters { readonly untilMs?: number /** Leave out actions ending in any of these, before the limit; see {@link AUDIT_ACCESS_ACTION_SUFFIXES}. */ readonly excludeActionSuffixes?: ReadonlyArray + /** + * `changes`: no data access (see {@link AUDIT_ACCESS_ACTION_SUFFIXES}) unless it was denied. + * `access`: only data access. + */ + readonly actionKind?: "changes" | "access" readonly limit: number readonly offset: number } @@ -175,6 +180,29 @@ const neverFail = ), ) +const isAccessAction = (action: string) => + Arr.some(AUDIT_ACCESS_ACTION_SUFFIXES, (suffix) => Str.endsWith(suffix)(action)) + +/** The suffix exclusion a listing applies: the caller's own, or the one `actionKind` implies. */ +const actionSuffixOpts = (filters: AuditLogListFilters) => { + if (filters.actionKind === "changes") + return { + excludeActionSuffixes: [ + ...(filters.excludeActionSuffixes ?? []), + ...AUDIT_ACCESS_ACTION_SUFFIXES, + ], + keepDeniedExcluded: true, + } + return { + ...(filters.excludeActionSuffixes !== undefined + ? { excludeActionSuffixes: filters.excludeActionSuffixes } + : undefined), + ...(filters.actionKind === "access" + ? { onlyActionSuffixes: AUDIT_ACCESS_ACTION_SUFFIXES } + : undefined), + } +} + /** Which optional filters bind, and the parameter values behind them. */ const listQueryInputs = (orgId: OrgId, filters: AuditLogListFilters) => { const since = filters.sinceMs === undefined ? undefined : DateTime.makeUnsafe(filters.sinceMs) @@ -193,9 +221,7 @@ const listQueryInputs = (orgId: OrgId, filters: AuditLogListFilters) => { requestId: filters.requestId !== undefined, since: since !== undefined, until: until !== undefined, - ...(filters.excludeActionSuffixes !== undefined - ? { excludeActionSuffixes: filters.excludeActionSuffixes } - : undefined), + ...actionSuffixOpts(filters), limit: filters.limit, offset: filters.offset, } @@ -376,7 +402,11 @@ export function makeMemoryAuditLog(): AuditLogServiceApi { (filters.untilMs === undefined || entry.occurredAt.getTime() <= filters.untilMs) && !Arr.some(filters.excludeActionSuffixes ?? [], (suffix) => Str.endsWith(suffix)(entry.action), - ), + ) && + (filters.actionKind !== "changes" || + !isAccessAction(entry.action) || + entry.outcome === "denied") && + (filters.actionKind !== "access" || isAccessAction(entry.action)), ) .sort((a, b) => { const byTime = b.occurredAt.getTime() - a.occurredAt.getTime() diff --git a/packages/domain/src/http/v2/audit-log.ts b/packages/domain/src/http/v2/audit-log.ts index 41d8d24449..3eec2afbbe 100644 --- a/packages/domain/src/http/v2/audit-log.ts +++ b/packages/domain/src/http/v2/audit-log.ts @@ -145,9 +145,22 @@ export const V2AuditLogEntry = Schema.Struct({ }) export type V2AuditLogEntry = Schema.Schema.Type +/** Which part of the log a listing covers: changes, data access (reads, tool calls, SQL), or both. */ +export const V2AuditLogKind = Schema.Literals(["all", "changes", "access"]).annotate({ + identifier: "AuditLogKind", + title: "Audit log kind", +}) +export type V2AuditLogKind = Schema.Schema.Type + /** Audit-log list query: standard pagination plus actor/action/resource/outcome/time filters. */ export const V2AuditLogQuery = Schema.Struct({ ...ListQuery.fields, + kind: Schema.optional( + V2AuditLogKind.annotate({ + description: + "`changes` leaves out data access (`*.read`, `*.called`, `*.sql_executed`, `*.request`) unless it was denied; `access` returns only data access; `all` (the default) returns both.", + }), + ), actor_type: Schema.optional( AuditActorType.annotate({ description: "Only return entries performed by this kind of actor.", diff --git a/packages/query-engine/src/ch/queries/audit-log.ts b/packages/query-engine/src/ch/queries/audit-log.ts index 69b305ade1..6321fadbeb 100644 --- a/packages/query-engine/src/ch/queries/audit-log.ts +++ b/packages/query-engine/src/ch/queries/audit-log.ts @@ -27,12 +27,19 @@ export interface AuditLogEntriesOpts { * limit. Fixed by the caller's code, never request input: they are inlined. */ readonly excludeActionSuffixes?: ReadonlyArray + /** Keep a denied entry even when its action is excluded: a refused read is still worth seeing. */ + readonly keepDeniedExcluded?: boolean + /** Only actions ending in one of these. Inlined, like `excludeActionSuffixes`. */ + readonly onlyActionSuffixes?: ReadonlyArray readonly limit: number readonly offset: number } const endsWith = defineCondFn<[CH.Expr, string]>("endsWith") +const endsWithAny = (action: CH.Expr, suffixes: ReadonlyArray) => + CH.or(...Arr.map(suffixes, (suffix) => endsWith(action, suffix))) + /** * One org's audit log, newest first, offset-paginated. Pinned to the managed * route: the table is written through `ingest` and does not exist in a BYO @@ -84,9 +91,12 @@ export function auditLogEntriesQuery(opts: AuditLogEntriesOpts) { CH.whenTrue(!!opts.since, () => $.OccurredAt.gte(param.dateTime("since"))), CH.whenTrue(!!opts.until, () => $.OccurredAt.lte(param.dateTime("until"))), opts.excludeActionSuffixes !== undefined && opts.excludeActionSuffixes.length > 0 - ? CH.not( - CH.or(...Arr.map(opts.excludeActionSuffixes, (suffix) => endsWith($.Action, suffix))), - ) + ? opts.keepDeniedExcluded === true + ? CH.or(CH.not(endsWithAny($.Action, opts.excludeActionSuffixes)), $.Outcome.eq("denied")) + : CH.not(endsWithAny($.Action, opts.excludeActionSuffixes)) + : undefined, + opts.onlyActionSuffixes !== undefined && opts.onlyActionSuffixes.length > 0 + ? endsWithAny($.Action, opts.onlyActionSuffixes) : undefined, ]) .orderBy(["occurredAt", "desc"], ["id", "desc"]) diff --git a/packages/views/src/ui/compact-search-input.ts b/packages/views/src/ui/compact-search-input.ts index e9ffc05a02..c37021aaad 100644 --- a/packages/views/src/ui/compact-search-input.ts +++ b/packages/views/src/ui/compact-search-input.ts @@ -1,4 +1,5 @@ import { cn } from "@maple/ui/lib/utils" +import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { icon } from "./icon" @@ -11,6 +12,8 @@ export type CompactSearchInputOptions = Readonly<{ ariaLabel: string onInput: (value: string) => M onClear: M + /** Enter in the field: for a field that applies its value rather than filtering as you type. */ + onEnter?: M clearLabel?: string className?: string }> @@ -31,6 +34,15 @@ export const compactSearchInput = (h: HtmlBuilder, options: CompactSearchI h.Type("search"), h.Value(options.value), h.OnInput(options.onInput), + ...(options.onEnter === undefined + ? [] + : [ + h.OnKeyDownPreventDefault((key) => + key === "Enter" && options.onEnter !== undefined + ? Option.some(options.onEnter) + : Option.none(), + ), + ]), ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), h.AriaLabel(options.ariaLabel), h.Class( From add940613ee86e32a4026362d56f38bd2bd4f9d1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:39:14 +0100 Subject: [PATCH 02/11] feat(audit-log): filter from an entry's detail, name members, show where it came from Following a trail meant copying an id and pasting it back as a filter. Every actor, action, resource, affected user and request in an entry's detail now has a Filter action that lists every entry with that value. Also: unnamed actors and membership changes show the member's name, a new Origin section shows surface, IP, country and request, metadata keys read as labels, and each entry has a link that opens the log on it (?entry=alog_...) and scrolls it into view. inlineCode gains size and tone options; the audit log detail and the alerts hub empty state move onto them, clearing 6 ui-kit baseline violations. --- .../components/settings/audit-log-filters.ts | 6 + apps/web/src/lab/audit-log-fixture.ts | 1 + apps/web/src/page/alerts/hub/view-empty.ts | 2 +- .../audit-log/audit-log.scene.test.ts | 96 +++++- apps/web/src/page/settings/audit-log/model.ts | 3 + .../web/src/page/settings/audit-log/update.ts | 86 +++++- .../page/settings/audit-log/view-detail.ts | 291 +++++++++++++----- apps/web/src/page/settings/audit-log/view.ts | 60 ++-- packages/views/src/ui/inline-code.ts | 18 +- scripts/oxlint-plugins/ui-kit-baseline.json | 2 - 10 files changed, 434 insertions(+), 131 deletions(-) diff --git a/apps/web/src/components/settings/audit-log-filters.ts b/apps/web/src/components/settings/audit-log-filters.ts index a35656a7ff..2347810332 100644 --- a/apps/web/src/components/settings/audit-log-filters.ts +++ b/apps/web/src/components/settings/audit-log-filters.ts @@ -1,5 +1,6 @@ import { auditActionLabel, auditResourceLabel } from "@maple/domain/audit-actions" import type { AuditActorType, AuditOutcome } from "@maple/domain/http" +import { decodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import { Array as Arr, Option, String as Str } from "effect" export interface AuditLogSearch { @@ -127,6 +128,11 @@ export function parseAuditLogSearch(search: AuditLogSearch): AuditLogFilters { } } +/** The entry a shared link points at (`?entry=alog_…`), as the internal id rows carry; none if malformed. */ +export function linkedAuditEntryId(search: { readonly entry?: string | undefined }): string | null { + return search.entry === undefined ? null : decodePublicId(PublicIdPrefixes.auditLogEntry, search.entry) +} + /** The filter one pasted value stands for: an id of a known shape, or an action name. */ export type AuditLookup = | { readonly key: "actorId"; readonly value: string } diff --git a/apps/web/src/lab/audit-log-fixture.ts b/apps/web/src/lab/audit-log-fixture.ts index 688198668c..a4514d404e 100644 --- a/apps/web/src/lab/audit-log-fixture.ts +++ b/apps/web/src/lab/audit-log-fixture.ts @@ -162,6 +162,7 @@ export const AUDIT_ENTRIES: ReadonlyArray = Arr.map( action: "member.role_changed", actor: "grace", source: "dashboard", + resource: { type: "member" }, affected: LINUS, changes: { fields: ["role"], before: { role: "org:member" }, after: { role: "org:admin" } }, ip: "192.0.2.44", diff --git a/apps/web/src/page/alerts/hub/view-empty.ts b/apps/web/src/page/alerts/hub/view-empty.ts index 5332b2de89..dff9c09e16 100644 --- a/apps/web/src/page/alerts/hub/view-empty.ts +++ b/apps/web/src/page/alerts/hub/view-empty.ts @@ -173,7 +173,7 @@ const templateTile = ( ), typography(h, { size: "sm", weight: "medium" }, [template.title]), ]), - inlineCode(h, { className: "self-start text-2xs text-muted-foreground" }, [template.summary]), + inlineCode(h, { size: "2xs", tone: "muted", className: "self-start" }, [template.summary]), ], ) } diff --git a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts index 84512e9897..555ded8600 100644 --- a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts @@ -1,5 +1,7 @@ import { Result, Array as Arr } from "effect" -import { expect as expectView, given, role, scene, withViewInputs } from "foldkit/scene" +import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { AUDIT_ENTRIES, AUDIT_MEMBERS, AUDIT_NOW } from "../../../lab/audit-log-fixture" import { describe, expect, test } from "vitest" import { PageOutMessage } from "../../contract" import * as CustomRange from "../../kit/custom-range" @@ -232,4 +234,96 @@ describe("audit log tab", () => { ) expect(picked.model.customRange).toBeNull() }) + + describe("an entry's detail", () => { + const search = { tab: "audit-log" } as const + const loadedWith = ( + searchFor: typeof search | { readonly tab: "audit-log"; readonly entry: string }, + ) => { + const entered = init(searchFor).model + const withMembers = update( + entered, + Message.CompletedFetchMembers({ members: AUDIT_MEMBERS }), + searchFor, + ).model + return update( + withMembers, + Message.CompletedFetchPage({ + request: entered.request, + resolvedSince: null, + result: Result.succeed({ data: AUDIT_ENTRIES, has_more: false, next_cursor: null }), + }), + searchFor, + ) + } + const sceneOf = (searchFor: Parameters[0]) => ({ + update: (model: ReturnType["model"], message: Message) => + update(model, message, searchFor), + view: withViewInputs(view, { + search: searchFor, + nowMs: AUDIT_NOW, + timeZone: "UTC", + apiKeys: [], + })(), + }) + + test("names a member the entry froze no name for, from the org's memberships", () => { + scene( + sceneOf(search), + given(loadedWith(search).model), + expectView(text("Linus Torvalds")).toExist(), + ) + }) + + test("Filter beside the actor lists that actor's entries of any type", () => { + const step = update( + loadedWith({ ...search }).model, + Message.ClickedPivot({ key: "actorId", value: "key_aXwpxqBkqtYwtBtmsGbR41" }), + { tab: "audit-log", actor_type: "user" }, + ) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&actor=key_aXwpxqBkqtYwtBtmsGbR41", + isReplace: true, + }), + ) + }) + + test("Filter beside the action also picks its resource type, so the action select shows it", () => { + const step = update( + loadedWith(search).model, + Message.ClickedPivot({ key: "action", value: "dashboard.updated" }), + search, + ) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&resource_type=dashboard&action=dashboard.updated", + isReplace: true, + }), + ) + }) + + test("a shared entry link opens that entry and scrolls to it", () => { + const target = AUDIT_ENTRIES[2] + if (target === undefined) throw new Error("fixture") + const linked = { + tab: "audit-log", + entry: encodePublicId(PublicIdPrefixes.auditLogEntry, target.id), + } as const + const loaded = loadedWith(linked) + expect(loaded.model.expanded).toEqual([target.id]) + expect(Arr.map(loaded.commands ?? [], ({ name }) => name)).toEqual(["ScrollToAuditEntry"]) + }) + + test("the Origin section names the country an entry came from", () => { + const target = AUDIT_ENTRIES[4] + if (target === undefined) throw new Error("fixture") + const opened = update( + loadedWith(search).model, + Message.ToggledEntry({ id: target.id }), + search, + ).model + scene(sceneOf(search), given(opened), expectView(text(/United Kingdom \(GB\)/)).toExist()) + }) + }) }) diff --git a/apps/web/src/page/settings/audit-log/model.ts b/apps/web/src/page/settings/audit-log/model.ts index 83d3a89d37..c9aa5cf8b9 100644 --- a/apps/web/src/page/settings/audit-log/model.ts +++ b/apps/web/src/page/settings/audit-log/model.ts @@ -116,5 +116,8 @@ export const Message = defineMessageUnion({ lastDay: CustomRange.CalendarDate, }, GotCustomRangeMessage: { message: CustomRange.Message }, + /** "Filter" beside a value in an entry's detail: the filter key and the value to filter by. */ + ClickedPivot: { key: Schema.String, value: Schema.String }, + CompletedScrollToEntry: {}, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/settings/audit-log/update.ts b/apps/web/src/page/settings/audit-log/update.ts index 9748a3fde0..841b426e23 100644 --- a/apps/web/src/page/settings/audit-log/update.ts +++ b/apps/web/src/page/settings/audit-log/update.ts @@ -8,6 +8,7 @@ import { type AuditLogFilters, auditLogKindOf, auditLogSearchFrom, + linkedAuditEntryId, parseAuditLogSearch, parseAuditLookup, } from "../../../components/settings/audit-log-filters" @@ -216,7 +217,10 @@ const dedupeById = (existing: ReadonlyArray, next: ReadonlyArra const navigateFilters = (model: Model, search: SettingsSearch, filters: AuditLogFilters): Return => ({ model, outMessage: PageOutMessage.RequestedNavigation({ - url: hrefOf(AppRoute.Settings({ search: { ...search, ...auditLogSearchFrom(filters) } })), + // A filter change leaves a shared entry link behind: it named one entry, not a view. + url: hrefOf( + AppRoute.Settings({ search: { ...search, ...auditLogSearchFrom(filters), entry: undefined } }), + ), isReplace: true, }), }) @@ -306,30 +310,76 @@ const withoutFilter = (filters: AuditLogFilters, key: string): AuditLogFilters = const liftCustom = (model: Model, customRange: CustomRange.Model | null): Model => ({ ...model, customRange }) +/** The detail row's DOM id: what a shared link scrolls to. */ +export const entryRowId = (id: string) => `audit-entry-row-${id}` + +/** A shared entry link opens with its row in view. */ +const ScrollToAuditEntry = Command.define("ScrollToAuditEntry", { + args: { id: Schema.String }, + messages: [Message.CompletedScrollToEntry], + execute: ({ id }) => + Effect.sync(() => { + document.getElementById(entryRowId(id))?.scrollIntoView({ block: "center" }) + return Message.CompletedScrollToEntry() + }), +}) + +/** The filters a detail pivot navigates to: the value replaces that filter, the rest stay. */ +const pivoted = (filters: AuditLogFilters, key: string, value: string): AuditLogFilters => { + switch (key) { + case "action": { + // The action select lives under its resource type, so pick that too. + const resourceType = Arr.headNonEmpty(Str.split(".")(value)) + return { ...filters, action: value, resourceType } + } + case "actorId": + // A person's entries are any actor type; a stale type filter would hide them. + return { ...filters, actorId: value, actorType: undefined } + case "resourceId": + case "affectedUserId": + case "requestId": + return { ...filters, [key]: value } + default: + return filters + } +} + export const update = (model: Model, message: Message, search: SettingsSearch): Return => { const filters = parseAuditLogSearch(search) return Message.match(message, { CompletedFetchMembers: ({ members }) => ({ model: { ...model, members } }), CompletedFetchPage: ({ request, resolvedSince, result }) => { if (!sameRequest(request, model.request)) return { model } + const linked = linkedAuditEntryId(search) return Result.match(result, { - onSuccess: (page) => ({ - model: { - ...model, - resolvedSince, - isWaiting: false, - isQuiet: false, - failure: null, - view: { - entries: - request.cursor === undefined - ? page.data - : dedupeById(model.view?.entries ?? [], page.data), - hasMore: page.has_more, - nextCursor: page.next_cursor, + onSuccess: (page) => { + // A shared link opens on its entry, once, when the first page holds it. + const reveal = + request.cursor === undefined && + linked !== null && + !model.expanded.includes(linked) && + Arr.some(page.data, (entry) => entry.id === linked) + return { + commands: reveal && linked !== null ? [ScrollToAuditEntry({ id: linked })] : [], + model: { + ...model, + resolvedSince, + isWaiting: false, + isQuiet: false, + failure: null, + view: { + entries: + request.cursor === undefined + ? page.data + : dedupeById(model.view?.entries ?? [], page.data), + hasMore: page.has_more, + nextCursor: page.next_cursor, + }, + expanded: + reveal && linked !== null ? [...model.expanded, linked] : model.expanded, }, - }, - }), + } + }, onFailure: (failure) => ({ model: { ...model, resolvedSince, isWaiting: false, isQuiet: false, failure }, }), @@ -426,6 +476,8 @@ export const update = (model: Model, message: Message, search: SettingsSearch): }), }), RemovedFilter: ({ key }) => navigateFilters(model, search, withoutFilter(filters, key)), + ClickedPivot: ({ key, value }) => navigateFilters(model, search, pivoted(filters, key, value)), + CompletedScrollToEntry: () => ({ model }), ClickedCustomRange: ({ timeZone }) => ({ model, commands: [ResolveAuditCustomToday({ timeZone })] }), ResolvedCustomToday: ({ timeZone, today, lastDay }) => { const range = filters.range diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index 506af359a4..174bbb919e 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -7,7 +7,7 @@ import { } from "@maple/domain/http/v2" import { EMPTY_VALUE } from "@maple/ui/lib/format" import type { AuditResourceLink } from "../../../components/settings/audit-log-resource" -import { Option, Schema, Array as Arr, Record as Rec, String as Str } from "effect" +import { DateTime, Option, Schema, Array as Arr, Record as Rec, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { AppRoute, hrefOf } from "../../../route" import * as CopyButton from "@maple/views/ui/copy-button" @@ -21,6 +21,8 @@ import { ACTOR_BADGES, SOURCE_LABELS } from "./view" import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { timestamp } from "@maple/views/ui/timestamp" +import { textLink } from "@maple/views/ui/text-link" +import { attributeFilterAction } from "@maple/views/ui/attributes/attribute-row" // `AuditLogDetail` and its parts. @@ -84,7 +86,7 @@ const changeValue = (h: H, value: unknown): Html => { const identifier = (h: H, model: Model, value: string, label: string): Html => { const key = `${label}:${value}` return hstack(h, { gap: "0.5", align: "center", inline: true, as: "span", className: "max-w-full" }, [ - inlineCode(h, { variant: "plain", className: "truncate text-2xs text-foreground" }, [value]), + inlineCode(h, { variant: "plain", size: "2xs", tone: "foreground", className: "truncate" }, [value]), CopyButton.view(h, { model: model.copies[key] ?? CopyButton.init(), toParentMessage: (message) => Message.GotCopyMessage({ key, message }), @@ -99,11 +101,7 @@ const identifier = (h: H, model: Model, value: string, label: string): Html => { const changesTable = (h: H, changes: V2AuditChanges): Html => table(h, { size: "sm", className: "font-mono" }, [ tableHeader(h, [ - tableRow(h, [ - tableHead(h, ["Field"], "w-40"), - tableHead(h, ["Before"]), - tableHead(h, ["After"]), - ]), + tableRow(h, [tableHead(h, ["Field"], "w-40"), tableHead(h, ["Before"]), tableHead(h, ["After"])]), ]), tableBody( h, @@ -131,7 +129,7 @@ const metadataList = (h: H, metadata: Readonly>): Readon { layout: "grid" }, Arr.map(keys, (key) => { const block = metadataBlock(metadata[key]) - return keyValue(h, { wrap: true, label: key }, [ + return keyValue(h, { wrap: true, label: metadataLabel(key) }, [ block !== null ? h.pre( [ @@ -150,6 +148,141 @@ const metadataList = (h: H, metadata: Readonly>): Readon ] } +/** A member's name for a `user_…` id, from the org's memberships; null for anyone else. */ +export const memberName = (model: Model, userId: string | null): string | null => + userId === null + ? null + : Option.getOrNull( + Option.map( + Arr.findFirst(model.members, (member) => member.value === userId), + (member) => member.label, + ), + ) + +/** "Filter" beside a value: lists every entry with that value instead. */ +const pivot = (h: H, key: string, value: string, what: string): Html => + attributeFilterAction(h, { + label: `Show every entry with this ${what}`, + text: "Filter", + onClick: Message.ClickedPivot({ key, value }), + }) + +const regionNames = new Intl.DisplayNames(["en"], { type: "region" }) + +/** `DE` -> "🇩🇪 Germany (DE)"; an unknown code reads as itself. */ +const countryText = (code: string): string => { + const upper = Str.toUpperCase(code) + if (!/^[A-Z]{2}$/.test(upper)) return code + const flag = String.fromCodePoint( + ...Arr.map( + Str.split("")(upper), + (char) => 0x1f1e6 + Option.getOrElse(Str.charCodeAt(0)(char), () => 65) - 65, + ), + ) + const name = regionNames.of(upper) + return name === undefined || name === upper ? `${flag} ${upper}` : `${flag} ${name} (${upper})` +} + +/** Labels for the metadata keys the backend writes; anything else is its key, made readable. */ +const METADATA_LABELS = { + endpoint: "Endpoint", + method: "Method", + path: "Path", + status: "HTTP status", + body: "Request body", + tool: "Tool", + surface: "Surface", + is_error: "Failed", + params: "Parameters", + sql: "SQL", + context: "Ran from", + start_time: "Window start", + end_time: "Window end", + row_count: "Rows returned", + error: "Error", + connector: "Chat connector", + workspace_id: "Chat workspace", + external_user_id: "Chat user ID", + display_name: "Chat user", + name: "Name", + scopes: "Scopes", + key: "Key", + platform: "Platform", +} satisfies Readonly> + +const isLabelledKey = (key: string): key is keyof typeof METADATA_LABELS => + Rec.has(METADATA_LABELS, key) + +const metadataLabel = (key: string): string => + isLabelledKey(key) ? METADATA_LABELS[key] : Str.capitalize(Str.replaceAll("_", " ")(key)) + +/** The audit log filtered to the minute around one entry, with that entry open. */ +export const entryHref = (entry: V2AuditLogEntry): string => { + const at = DateTime.toEpochMillis(DateTime.makeUnsafe(entry.occurred_at)) + return hrefOf( + AppRoute.Settings({ + search: { + tab: "audit-log", + kind: "all", + since: new Date(at - 60_000).toISOString(), + until: new Date(at + 60_000).toISOString(), + entry: encodePublicId(PublicIdPrefixes.auditLogEntry, entry.id), + }, + }), + ) +} + +const originSection = (h: H, model: Model, entry: V2AuditLogEntry): ReadonlyArray => { + if (entry.origin_ip === null && entry.origin_country === null && entry.request_id === null) return [] + return [ + h.section( + [h.Class("space-y-1.5")], + [ + eyebrow(h, { variant: "mono", as: "h4" }, ["Origin"]), + keyValueList(h, { layout: "grid", className: DETAIL_GRID }, [ + keyValue(h, { wrap: true, label: "Surface" }, [ + h.span([], [SOURCE_LABELS[entry.source]]), + ]), + ...(entry.origin_ip === null + ? [] + : [ + keyValue(h, { wrap: true, label: "IP address" }, [ + identifier(h, model, entry.origin_ip, "IP address"), + ]), + ]), + ...(entry.origin_country === null + ? [] + : [ + keyValue(h, { wrap: true, label: "Country" }, [ + h.span([], [countryText(entry.origin_country)]), + ]), + ]), + ...(entry.request_id === null + ? [] + : [ + keyValue(h, { wrap: true, label: "Request" }, [ + hstack( + h, + { gap: "1.5", align: "center", wrap: true, shrink: true, as: "span" }, + [ + identifier(h, model, entry.request_id, "Request id"), + pivot(h, "requestId", entry.request_id, "request"), + ], + ), + ]), + ]), + ]), + ], + ), + ] +} + +const DETAIL_GRID = + "@2xl:grid-cols-[minmax(6rem,max-content)_minmax(0,1fr)_minmax(6rem,max-content)_minmax(0,1fr)] @2xl:gap-x-6" + +const valueRow = (h: H, children: ReadonlyArray): Html => + hstack(h, { gap: "1.5", align: "center", wrap: true, shrink: true, as: "span" }, children) + export const auditDetail = ( h: H, model: Model, @@ -163,104 +296,92 @@ export const auditDetail = ( const actionText = auditActionLabel(entry.action) const changes = entry.changes const metadata = entry.metadata + const actorName = entry.actor_name ?? memberName(model, entry.actor_id) + const affectedName = memberName(model, entry.affected_user) return h.div( [h.Class("border-border-subtle space-y-4 border-t px-4 py-3 @md:pl-11")], [ - keyValueList( - h, - { - layout: "grid", - className: - "@2xl:grid-cols-[minmax(6rem,max-content)_minmax(0,1fr)_minmax(6rem,max-content)_minmax(0,1fr)] @2xl:gap-x-6", - }, - [ - keyValue(h, { wrap: true, label: "Occurred" }, [full(entry.occurred_at)]), - keyValue(h, { wrap: true, label: "Recorded" }, [full(entry.recorded_at)]), - keyValue(h, { wrap: true, label: "Actor" }, [ - hstack(h, { gap: "1.5", align: "center", wrap: true, shrink: true, as: "span" }, [ - badge(h, { variant: actor.variant, size: "sm" }, [actor.label]), - ...(entry.actor_name === null ? [] : [h.span([], [entry.actor_name])]), - ...(entry.actor_id === null - ? [] - : [identifier(h, model, entry.actor_id, "Actor id")]), - ]), - ]), - keyValue(h, { wrap: true, label: "Source" }, [ - h.span([], [SOURCE_LABELS[entry.source]]), - ...(entry.origin_ip === null && entry.origin_country === null + keyValueList(h, { layout: "grid", className: DETAIL_GRID }, [ + keyValue(h, { wrap: true, label: "Occurred" }, [full(entry.occurred_at)]), + keyValue(h, { wrap: true, label: "Recorded" }, [full(entry.recorded_at)]), + keyValue(h, { wrap: true, label: "Actor" }, [ + valueRow(h, [ + badge(h, { variant: actor.variant, size: "sm" }, [actor.label]), + ...(actorName === null ? [] : [h.span([], [actorName])]), + ...(entry.actor_id === null ? [] : [ - typography(h, { tone: "muted" }, [ - " · ", - entry.origin_ip ?? "unknown IP", - ...(entry.origin_country === null - ? [] - : [` (${entry.origin_country})`]), - ]), + identifier(h, model, entry.actor_id, "Actor id"), + pivot(h, "actorId", entry.actor_id, "actor"), ]), ]), - keyValue(h, { wrap: true, label: "Action" }, [ - hstack(h, { gap: "1.5", wrap: true, as: "span" }, [ - ...(actionText === null ? [] : [h.span([], [actionText])]), - inlineCode(h, { variant: "plain", className: "text-2xs text-foreground" }, [ - entry.action, - ]), - entry.outcome === "denied" - ? badge(h, { variant: "crit", size: "sm" }, ["Denied"]) - : badge(h, { variant: "ok", size: "sm" }, ["Allowed"]), - ]), - ...(entry.denial_reason === null - ? [] - : [h.p([h.Class("text-muted-foreground mt-0.5")], [entry.denial_reason])]), + ]), + keyValue(h, { wrap: true, label: "Action" }, [ + valueRow(h, [ + ...(actionText === null ? [] : [h.span([], [actionText])]), + inlineCode(h, { variant: "plain", size: "2xs", tone: "foreground" }, [entry.action]), + entry.outcome === "denied" + ? badge(h, { variant: "crit", size: "sm" }, ["Denied"]) + : badge(h, { variant: "ok", size: "sm" }, ["Allowed"]), + ...(actionText === null ? [] : [pivot(h, "action", entry.action, "action")]), ]), - keyValue(h, { wrap: true, label: "Resource" }, [ - entry.resource_type === null && entry.resource_id === null - ? typography(h, { tone: "subtle" }, [EMPTY_VALUE]) - : hstack( - h, - { gap: "1.5", align: "center", wrap: true, shrink: true, as: "span" }, - [ - ...(entry.resource_type === null - ? [] - : [ - h.span( - [], - [ - auditResourceLabel(entry.resource_type) ?? - entry.resource_type, - ], - ), - ]), - ...(entry.resource_id === null - ? [] - : [identifier(h, model, entry.resource_id, "Resource id")]), - ], - ), - ]), - ...(entry.affected_user === null + ...(entry.denial_reason === null ? [] : [ - keyValue(h, { wrap: true, label: "Affected user" }, [ - identifier(h, model, entry.affected_user, "Affected user id"), + typography(h, { tone: "muted", as: "p", className: "mt-0.5" }, [ + entry.denial_reason, ]), ]), - ...(entry.request_id === null - ? [] - : [ - keyValue(h, { wrap: true, label: "Request" }, [ - identifier(h, model, entry.request_id, "Request id"), + ]), + keyValue(h, { wrap: true, label: "Resource" }, [ + entry.resource_type === null && entry.resource_id === null + ? typography(h, { tone: "subtle" }, [EMPTY_VALUE]) + : valueRow(h, [ + ...(entry.resource_type === null + ? [] + : [ + h.span( + [], + [ + auditResourceLabel(entry.resource_type) ?? + entry.resource_type, + ], + ), + ]), + ...(entry.resource_id === null + ? [] + : [ + identifier(h, model, entry.resource_id, "Resource id"), + pivot(h, "resourceId", entry.resource_id, "resource"), + ]), + ]), + ]), + ...(entry.affected_user === null + ? [] + : [ + keyValue(h, { wrap: true, label: "Affected user" }, [ + valueRow(h, [ + ...(affectedName === null ? [] : [h.span([], [affectedName])]), + identifier(h, model, entry.affected_user, "Affected user id"), + pivot(h, "affectedUserId", entry.affected_user, "affected user"), ]), ]), - keyValue(h, { wrap: true, label: "Entry" }, [ + ]), + keyValue(h, { wrap: true, label: "Entry" }, [ + valueRow(h, [ identifier( h, model, encodePublicId(PublicIdPrefixes.auditLogEntry, entry.id), "Entry id", ), + textLink(h, { href: entryHref(entry) }, [ + typography(h, { size: "2xs" }, ["Link to entry"]), + ]), ]), - ], - ), + ]), + ]), + ...originSection(h, model, entry), ...(changes !== null && changes.fields.length > 0 ? [ h.section( diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 27d6b39798..22705fb914 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -1,4 +1,9 @@ -import { auditActionLabel, AuditResourceLabels, AuditResources } from "@maple/domain/audit-actions" +import { + auditActionLabel, + AuditResourceLabels, + AuditResources, + auditResourceLabel, +} from "@maple/domain/audit-actions" import type { AuditActorType, AuditLogSource } from "@maple/domain/http" import { encodePublicId, PublicIdPrefixes, type V2ApiKey, type V2AuditLogEntry } from "@maple/domain/http/v2" import { EMPTY_VALUE } from "@maple/ui/lib/format" @@ -41,8 +46,8 @@ import { relativeTime } from "../../kit/relative-time" import { filteredEmpty } from "@maple/views/ui/signal-empty-state" import { settingsSection } from "../kit/section" import { Message, type Model } from "./model" -import { ANY, ANYONE } from "./update" -import { auditDetail, resourceHref } from "./view-detail" +import { ANY, ANYONE, entryRowId } from "./update" +import { auditDetail, memberName, resourceHref } from "./view-detail" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, hstackClass, vstack, vstackClass } from "@maple/views/ui/stack" import { Array as Arr, String as Str, Option, Order, Record as Rec } from "effect" @@ -113,8 +118,11 @@ const abbreviateId = (id: string): string => { ) } -const actorDisplayName = (entry: V2AuditLogEntry): string | null => - entry.actor_name ?? (entry.actor_id === null ? null : abbreviateId(entry.actor_id)) +/** The name frozen on the entry, else the member's current name, else a short id. */ +const actorDisplayName = (model: Model, entry: V2AuditLogEntry): string | null => + entry.actor_name ?? + memberName(model, entry.actor_id) ?? + (entry.actor_id === null ? null : abbreviateId(entry.actor_id)) const rangeLabel = (range: AuditDateRange, timeZone: string): string => { if (range.kind === "all") return "All time" @@ -321,18 +329,15 @@ const personFilter = ( // ROWS -const actorCell = (h: H, entry: V2AuditLogEntry): Html => { +const actorCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { const actor = ACTOR_BADGES[entry.actor_type] - const name = actorDisplayName(entry) + const name = actorDisplayName(model, entry) + const isNamed = entry.actor_name !== null || memberName(model, entry.actor_id) !== null return h.div( [h.Class(cn(COL.actor, hstackClass({ gap: "1.5" })))], [ entry.actor_type === "user" - ? avatarFallback( - h, - { className: "size-4 shrink-0 text-4xs" }, - initialsOf(entry.actor_name ?? entry.actor_id ?? ""), - ) + ? avatarFallback(h, { className: "size-4 shrink-0 text-4xs" }, initialsOf(name ?? "")) : badge(h, { variant: actor.variant, size: "sm", className: "shrink-0" }, [actor.label]), ...(name === null ? [] @@ -341,13 +346,10 @@ const actorCell = (h: H, entry: V2AuditLogEntry): Html => { h, { text: - entry.actor_name !== null && entry.actor_id !== null + isNamed && entry.actor_id !== null ? `${name} (${entry.actor_id})` : (entry.actor_id ?? name), - className: cn( - "text-xs", - entry.actor_name === null && "text-muted-foreground", - ), + className: cn("text-xs", !isNamed && "text-muted-foreground"), }, [name], ), @@ -371,8 +373,8 @@ const actionLabel = (h: H, action: string): ReadonlyArray => { const deniedBadge = (h: H) => badge(h, { variant: "crit", size: "sm", className: "shrink-0" }, ["Denied"]) -const resourceCell = (h: H, entry: V2AuditLogEntry): Html => { - const display = auditResourceDisplay(entry) +const resourceCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { + const display = withMemberName(model, entry, auditResourceDisplay(entry)) if (display === null) return typography(h, { size: "xs", tone: "muted" }, [EMPTY_VALUE]) if (display.link === null) return truncatedText(h, { text: display.text, className: "text-xs" }) return textLink( @@ -388,13 +390,25 @@ const resourceCell = (h: H, entry: V2AuditLogEntry): Html => { ) } +/** A membership change acts on a person: name them rather than showing their id. */ +const withMemberName = ( + model: Model, + entry: V2AuditLogEntry, + display: ReturnType, +): ReturnType => { + const name = memberName(model, entry.affected_user) + return display === null || name === null || entry.resource_type !== "member" + ? display + : { ...display, text: `${auditResourceLabel(entry.resource_type) ?? "Member"} · ${name}` } +} + const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs): Html => { const isExpanded = model.expanded.includes(entry.id) const detailId = `audit-entry-${entry.id}` const isDenied = entry.outcome === "denied" const label = auditActionLabel(entry.action) ?? entry.action return h.div( - [h.Key(entry.id), ...(isExpanded ? [h.Class("bg-surface-faint")] : [])], + [h.Key(entry.id), h.Id(entryRowId(entry.id)), ...(isExpanded ? [h.Class("bg-surface-faint")] : [])], [ hstack( h, @@ -433,7 +447,7 @@ const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs }), ], ), - actorCell(h, entry), + actorCell(h, model, entry), h.div( [h.Class(cn(COL.action, vstackClass({ shrink: true })))], [ @@ -451,7 +465,7 @@ const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs : []), ], ), - h.div([h.Class(COL.resource)], [resourceCell(h, entry)]), + h.div([h.Class(COL.resource)], [resourceCell(h, model, entry)]), h.span( [h.Class(COL.source)], [badge(h, { variant: "outline", size: "sm" }, [SOURCE_LABELS[entry.source]])], @@ -659,7 +673,7 @@ export const view = Submodel.defineView((model, inpu ]), ]), hstack(h, { gap: "3", wrap: true, align: "center", justify: "between" }, [ - hstack(h, { gap: "3", wrap: true }, [ + hstack(h, { gap: "3", wrap: true, className: "min-w-0 max-w-full" }, [ segmentedSelect(h, { size: "sm", ariaLabel: "Actor type", diff --git a/packages/views/src/ui/inline-code.ts b/packages/views/src/ui/inline-code.ts index 454f329afb..de7a8c684d 100644 --- a/packages/views/src/ui/inline-code.ts +++ b/packages/views/src/ui/inline-code.ts @@ -9,15 +9,29 @@ import type { Html, HtmlBuilder } from "foldkit/html" export const inlineCodeClassName = (variant: "chip" | "plain", className?: string): string => cn(inlineCodeStyles, variant === "chip" ? inlineCodeChipStyles : inlineCodePlainStyles, className) +const SIZE = { inherit: "", "2xs": "text-2xs" } as const +const TONE = { inherit: "", foreground: "text-foreground", muted: "text-muted-foreground" } as const + /** `InlineCode`: inline monospace for identifiers and commands inside prose. */ export const inlineCode = ( h: HtmlBuilder, - options: { readonly variant?: "chip" | "plain"; readonly className?: string }, + options: { + readonly variant?: "chip" | "plain" + /** `2xs` for ids in dense detail grids; otherwise it follows the surrounding text. */ + readonly size?: keyof typeof SIZE + readonly tone?: keyof typeof TONE + readonly className?: string + }, children: ReadonlyArray, ): Html => h.code( [ - h.Class(inlineCodeClassName(options.variant ?? "chip", options.className)), + h.Class( + inlineCodeClassName( + options.variant ?? "chip", + cn(SIZE[options.size ?? "inherit"], TONE[options.tone ?? "inherit"], options.className), + ), + ), h.DataAttribute("slot", "inline-code"), ], children, diff --git a/scripts/oxlint-plugins/ui-kit-baseline.json b/scripts/oxlint-plugins/ui-kit-baseline.json index 7ab0ea764e..85a84418bb 100644 --- a/scripts/oxlint-plugins/ui-kit-baseline.json +++ b/scripts/oxlint-plugins/ui-kit-baseline.json @@ -39,7 +39,6 @@ "apps/web/src/page/alerts/editor/view-notify.ts": 4, "apps/web/src/page/alerts/editor/view-sql.ts": 14, "apps/web/src/page/alerts/editor/view-templates.ts": 2, - "apps/web/src/page/alerts/hub/view-empty.ts": 2, "apps/web/src/page/alerts/hub/view-inbox.ts": 2, "apps/web/src/page/alerts/hub/view-maintenance.ts": 1, "apps/web/src/page/alerts/hub/view-oncall.ts": 1, @@ -203,7 +202,6 @@ "apps/web/src/page/settings/account/profile/view.ts": 3, "apps/web/src/page/settings/account/two-factor/view.ts": 1, "apps/web/src/page/settings/ai/view.ts": 2, - "apps/web/src/page/settings/audit-log/view-detail.ts": 4, "apps/web/src/page/settings/audit-log/view.ts": 3, "apps/web/src/page/settings/automation/view.ts": 1, "apps/web/src/page/settings/billing/details/view.ts": 1, From 8b8805ad7493c54c52c465e4bd00c9602f592973 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:44:49 +0100 Subject: [PATCH 03/11] feat(audit-log): day headings, a red edge on refusals, quieter reads The list was one undifferentiated column of relative times. Rows now sit under Today / Yesterday / date headings counted in the viewer's zone, a denied entry carries a red left edge, and in the All view allowed reads, tool calls and SQL runs are muted so changes stand out. The Source column shows from a narrower width. dayLabel/dayKey move from the users detail into page/kit/day-label so both pages share them. The audit log list clears its last 3 ui-kit baseline violations. --- apps/web/src/lab/audit-log-fixture.ts | 3 +- apps/web/src/page/kit/day-label.test.ts | 32 ++++++++ apps/web/src/page/kit/day-label.ts | 40 ++++++++++ .../audit-log/audit-log.scene.test.ts | 9 +++ apps/web/src/page/settings/audit-log/view.ts | 75 ++++++++++++++++--- apps/web/src/page/users/view-detail.test.ts | 19 +---- apps/web/src/page/users/view-detail.ts | 33 +------- scripts/oxlint-plugins/ui-kit-baseline.json | 1 - 8 files changed, 150 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/page/kit/day-label.test.ts create mode 100644 apps/web/src/page/kit/day-label.ts diff --git a/apps/web/src/lab/audit-log-fixture.ts b/apps/web/src/lab/audit-log-fixture.ts index a4514d404e..36233c61b2 100644 --- a/apps/web/src/lab/audit-log-fixture.ts +++ b/apps/web/src/lab/audit-log-fixture.ts @@ -60,7 +60,8 @@ const entry = (wire: Wire): V2AuditLogEntry => { actor_avatar_url: null, affected_user: wire.affected ?? null, source: wire.source, - resource_type: wire.resource?.type ?? null, + // The service derives the type from the action, so every stored entry has one. + resource_type: wire.resource?.type ?? Arr.headNonEmpty(Str.split(".")(wire.action)), resource_id: wire.resource?.prefix === undefined ? null diff --git a/apps/web/src/page/kit/day-label.test.ts b/apps/web/src/page/kit/day-label.test.ts new file mode 100644 index 0000000000..0e4330c69a --- /dev/null +++ b/apps/web/src/page/kit/day-label.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest" +import { dayKey, dayLabel } from "./day-label" + +// Stockholm skipped 02:00-03:00 on 30 Mar 2025, so that local day was 23 hours long. +const clock = { nowMs: Date.parse("2025-03-30T22:30:00Z"), timeZone: "Europe/Stockholm" } + +describe("dayLabel", () => { + it("calls the short day before a spring-forward night yesterday", () => { + expect(dayLabel(Date.parse("2025-03-30T10:00:00Z"), clock)).toBe("Yesterday") + }) + + it("names the day before that by date", () => { + expect(dayLabel(Date.parse("2025-03-29T12:00:00Z"), clock)).toBe("Sat 29 Mar") + }) + + it("calls the current local day today", () => { + expect(dayLabel(Date.parse("2025-03-30T22:10:00Z"), clock)).toBe("Today") + }) +}) + +describe("dayKey", () => { + it("splits days at the zone's midnight, not UTC's", () => { + const lateUtc = Date.parse("2025-03-30T22:10:00Z") + const earlierUtc = Date.parse("2025-03-30T21:50:00Z") + // 00:10 and 23:50 in Stockholm: two days there, one day in UTC. + expect(dayKey(lateUtc, "Europe/Stockholm")).not.toBe(dayKey(earlierUtc, "Europe/Stockholm")) + expect(dayKey(lateUtc, "UTC")).toBe(dayKey(earlierUtc, "UTC")) + expect(dayKey(lateUtc, "Asia/Tokyo")).not.toBe( + dayKey(Date.parse("2025-03-30T14:00:00Z"), "Asia/Tokyo"), + ) + }) +}) diff --git a/apps/web/src/page/kit/day-label.ts b/apps/web/src/page/kit/day-label.ts new file mode 100644 index 0000000000..e0a5c7b898 --- /dev/null +++ b/apps/web/src/page/kit/day-label.ts @@ -0,0 +1,40 @@ +import { Array as Arr, Option } from "effect" + +// Calendar days in a time zone, for lists grouped by day ("Today", "Yesterday", "Mon 2 Nov"). + +/** The page's resolved clock: now, and the zone days are counted in. */ +export type DayClock = Readonly<{ nowMs: number; timeZone: string }> + +const timeFormat = (timeZone: string, options: Intl.DateTimeFormatOptions) => + new Intl.DateTimeFormat("en-GB", { timeZone, ...options }) + +/** The local calendar day of `atMs` as a sortable key: two instants share it when they share a day. */ +export const dayKey = (atMs: number, timeZone: string): string => + timeFormat(timeZone, { year: "numeric", month: "2-digit", day: "2-digit" }).format(atMs) + +/** The local calendar day as a day count, so "yesterday" is one less even across a DST change. */ +const dayNumber = (atMs: number, timeZone: string): number => { + const parts = timeFormat(timeZone, { year: "numeric", month: "numeric", day: "numeric" }).formatToParts( + atMs, + ) + const part = (type: Intl.DateTimeFormatPartTypes): number => + Number( + Option.getOrElse( + Option.map( + Arr.findFirst(parts, (entry) => entry.type === type), + (entry) => entry.value, + ), + () => "0", + ), + ) + return Date.UTC(part("year"), part("month") - 1, part("day")) / 86_400_000 +} + +/** "Today", "Yesterday", else "Mon 2 Nov". */ +export const dayLabel = (atMs: number, clock: DayClock): string => { + const day = dayNumber(atMs, clock.timeZone) + const today = dayNumber(clock.nowMs, clock.timeZone) + if (day === today) return "Today" + if (day === today - 1) return "Yesterday" + return timeFormat(clock.timeZone, { weekday: "short", day: "numeric", month: "short" }).format(atMs) +} diff --git a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts index 555ded8600..bf208a9e20 100644 --- a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts @@ -315,6 +315,15 @@ describe("audit log tab", () => { expect(Arr.map(loaded.commands ?? [], ({ name }) => name)).toEqual(["ScrollToAuditEntry"]) }) + test("rows sit under a heading for each day, counted in the viewer's zone", () => { + scene( + sceneOf(search), + given(loadedWith(search).model), + expectView(role("heading", { name: "Today" })).toExist(), + expectView(role("heading", { name: "Yesterday" })).toExist(), + ) + }) + test("the Origin section names the country an entry came from", () => { const target = AUDIT_ENTRIES[4] if (target === undefined) throw new Error("fixture") diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 22705fb914..3d5081bbd7 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -50,7 +50,8 @@ import { ANY, ANYONE, entryRowId } from "./update" import { auditDetail, memberName, resourceHref } from "./view-detail" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, hstackClass, vstack, vstackClass } from "@maple/views/ui/stack" -import { Array as Arr, String as Str, Option, Order, Record as Rec } from "effect" +import { Array as Arr, DateTime, String as Str, Option, Order, Record as Rec } from "effect" +import { dayKey, dayLabel } from "../../kit/day-label" import { textLink } from "@maple/views/ui/text-link" type H = HtmlBuilder @@ -102,7 +103,7 @@ const COL = { actor: "min-w-0 flex-1 @md:w-38 @md:flex-none", action: "min-w-0 basis-full pl-5 @md:basis-0 @md:flex-1 @md:pl-0", resource: "hidden w-56 min-w-0 shrink-0 @3xl:block", - source: "hidden w-20 shrink-0 @2xl:block", + source: "hidden w-20 shrink-0 @xl:block", outcome: "hidden w-15 shrink-0 @md:block", } @@ -337,7 +338,11 @@ const actorCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { [h.Class(cn(COL.actor, hstackClass({ gap: "1.5" })))], [ entry.actor_type === "user" - ? avatarFallback(h, { className: "size-4 shrink-0 text-4xs" }, initialsOf(name ?? "")) + ? avatarFallback( + h, + { className: "size-4 shrink-0" }, + typography(h, { size: "4xs" }, [initialsOf(name ?? "")]), + ) : badge(h, { variant: actor.variant, size: "sm", className: "shrink-0" }, [actor.label]), ...(name === null ? [] @@ -358,11 +363,20 @@ const actorCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { ) } -const actionLabel = (h: H, action: string): ReadonlyArray => { +const ACCESS_SUFFIXES = [".read", ".called", ".sql_executed", ".request"] + +/** A read, tool call, SQL run or API request that went through: background next to a change. */ +const isQuietAccess = (entry: V2AuditLogEntry): boolean => + entry.outcome === "allowed" && Arr.some(ACCESS_SUFFIXES, (suffix) => Str.endsWith(suffix)(entry.action)) + +const actionLabel = (h: H, action: string, isQuiet: boolean): ReadonlyArray => { const label = auditActionLabel(action) if (label === null) return [truncatedText(h, { mono: true, text: action, className: "text-xs" })] return [ - truncatedText(h, { text: label, className: typographyClass({ size: "xs", tone: "foreground" }) }), + truncatedText(h, { + text: label, + className: typographyClass({ size: "xs", tone: isQuiet ? "muted" : "foreground" }), + }), truncatedText(h, { mono: true, text: action, @@ -383,10 +397,10 @@ const resourceCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { href: resourceHref(display.link), tone: "inherit", className: - "relative z-10 block truncate text-xs text-foreground underline decoration-muted-foreground/40 hover:decoration-foreground active:decoration-foreground", + "relative z-10 block truncate underline decoration-muted-foreground/40 hover:decoration-foreground active:decoration-foreground", attributes: [h.Title(display.text)], }, - [display.text], + [typography(h, { size: "xs", tone: "foreground" }, [display.text])], ) } @@ -415,8 +429,11 @@ const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs { align: "center", wrap: true, - className: - "hover:bg-surface-sunken active:bg-surface-sunken relative w-full gap-x-3 gap-y-1 px-4 py-2.5 @md:flex-nowrap", + className: cn( + "hover:bg-surface-sunken active:bg-surface-sunken relative w-full gap-x-3 gap-y-1 border-l-2 py-2.5 pr-4 pl-3.5 @md:flex-nowrap", + // A refused attempt is the entry worth spotting while scrolling. + isDenied ? "border-l-severity-error" : "border-l-transparent", + ), }, [ pressable( @@ -452,7 +469,11 @@ const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs [h.Class(cn(COL.action, vstackClass({ shrink: true })))], [ hstack(h, { gap: "1.5", shrink: true }, [ - vstack(h, { shrink: true }, actionLabel(h, entry.action)), + vstack( + h, + { shrink: true }, + actionLabel(h, entry.action, isQuietAccess(entry)), + ), ...(isDenied ? [h.span([h.Class("@md:hidden")], [deniedBadge(h)])] : []), ]), ...(isDenied && entry.denial_reason !== null @@ -530,10 +551,42 @@ const listBody = (h: H, model: Model, inputs: ViewInputs, isFiltered: boolean): ) return h.div( [h.Class("divide-border divide-y")], - [headerRow(h), ...Arr.map(view.entries, (entry) => auditRow(h, model, entry, inputs))], + [headerRow(h), ...withDayHeadings(h, model, view.entries, inputs)], ) } +/** The rows with a heading wherever the day changes ("Today", "Yesterday", "Mon 2 Nov"). */ +const withDayHeadings = ( + h: H, + model: Model, + entries: ReadonlyArray, + inputs: ViewInputs, +): ReadonlyArray => { + const clock = { nowMs: inputs.nowMs, timeZone: inputs.timeZone } + const atMs = (entry: V2AuditLogEntry) => DateTime.toEpochMillis(DateTime.makeUnsafe(entry.occurred_at)) + return Arr.flatMap(entries, (entry, index) => { + const day = dayKey(atMs(entry), inputs.timeZone) + const previous = index === 0 ? undefined : entries[index - 1] + const isNewDay = previous === undefined || dayKey(atMs(previous), inputs.timeZone) !== day + return [ + ...(isNewDay + ? [ + h.div( + [ + h.Key(`day-${day}`), + h.Class("bg-surface-faint px-4 py-1.5"), + h.Role("heading"), + h.AriaLevel(3), + ], + [eyebrow(h, { variant: "mono" }, [dayLabel(atMs(entry), clock)])], + ), + ] + : []), + auditRow(h, model, entry, inputs), + ] + }) +} + /** Filters that have no control of their own, shown as removable chips (a looked-up id, an action). */ const filterChips = (h: H, model: Model, filters: AuditLogFilters): ReadonlyArray => { const memberName = (userId: string) => diff --git a/apps/web/src/page/users/view-detail.test.ts b/apps/web/src/page/users/view-detail.test.ts index 2c403de837..dfb99241a0 100644 --- a/apps/web/src/page/users/view-detail.test.ts +++ b/apps/web/src/page/users/view-detail.test.ts @@ -1,23 +1,6 @@ import { describe, expect, it } from "vitest" import { USER_DETAIL } from "../../lab/users-fixture" -import { dayLabel, indexedTabState, tabCount } from "./view-detail" - -// Stockholm skipped 02:00-03:00 on 30 Mar 2025, so that local day was 23 hours long. -const clock = { nowMs: Date.parse("2025-03-30T22:30:00Z"), timeZone: "Europe/Stockholm" } - -describe("dayLabel", () => { - it("calls the short day before a spring-forward night yesterday", () => { - expect(dayLabel(Date.parse("2025-03-30T10:00:00Z"), clock)).toBe("Yesterday") - }) - - it("names the day before that by date", () => { - expect(dayLabel(Date.parse("2025-03-29T12:00:00Z"), clock)).toBe("Sat 29 Mar") - }) - - it("calls the current local day today", () => { - expect(dayLabel(Date.parse("2025-03-30T22:10:00Z"), clock)).toBe("Today") - }) -}) +import { indexedTabState, tabCount } from "./view-detail" describe("indexedTabState", () => { it("tells a warehouse without the per-user rollup apart from a user with no rows", () => { diff --git a/apps/web/src/page/users/view-detail.ts b/apps/web/src/page/users/view-detail.ts index f65f9b81b5..7ffc383ad0 100644 --- a/apps/web/src/page/users/view-detail.ts +++ b/apps/web/src/page/users/view-detail.ts @@ -1,3 +1,4 @@ +import { dayKey, dayLabel } from "../kit/day-label" import { countLabel, EMPTY_VALUE, @@ -31,7 +32,7 @@ import { statusDot } from "@maple/views/ui/status-dot" import { textLink } from "@maple/views/ui/text-link" import { typography, typographyClass } from "@maple/views/ui/typography" import { serviceDot } from "@maple/views/ui/values" -import { Array as Arr, Option, Order } from "effect" +import { Array as Arr, Order } from "effect" import { th, toolTable, @@ -81,36 +82,6 @@ const timeFormat = (timeZone: string, options: Intl.DateTimeFormatOptions) => const clockOf = (atMs: number, timeZone: string): string => timeFormat(timeZone, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(atMs) -const dayKey = (atMs: number, timeZone: string): string => - timeFormat(timeZone, { year: "numeric", month: "2-digit", day: "2-digit" }).format(atMs) - -/** The local calendar day as a day count, so "yesterday" is one less even across a DST change. */ -const dayNumber = (atMs: number, timeZone: string): number => { - const parts = timeFormat(timeZone, { year: "numeric", month: "numeric", day: "numeric" }).formatToParts( - atMs, - ) - const part = (type: Intl.DateTimeFormatPartTypes): number => - Number( - Option.getOrElse( - Option.map( - Arr.findFirst(parts, (entry) => entry.type === type), - (entry) => entry.value, - ), - () => "0", - ), - ) - return Date.UTC(part("year"), part("month") - 1, part("day")) / 86_400_000 -} - -/** "Today", "Yesterday", else "Mon 2 Nov". */ -export const dayLabel = (atMs: number, clock: Clock): string => { - const day = dayNumber(atMs, clock.timeZone) - const today = dayNumber(clock.nowMs, clock.timeZone) - if (day === today) return "Today" - if (day === today - 1) return "Yesterday" - return timeFormat(clock.timeZone, { weekday: "short", day: "numeric", month: "short" }).format(atMs) -} - // --- header ------------------------------------------------------------------------------------- /** The page title block: avatar, name, then the ids and when they were last active. */ diff --git a/scripts/oxlint-plugins/ui-kit-baseline.json b/scripts/oxlint-plugins/ui-kit-baseline.json index 85a84418bb..4db5ad8649 100644 --- a/scripts/oxlint-plugins/ui-kit-baseline.json +++ b/scripts/oxlint-plugins/ui-kit-baseline.json @@ -202,7 +202,6 @@ "apps/web/src/page/settings/account/profile/view.ts": 3, "apps/web/src/page/settings/account/two-factor/view.ts": 1, "apps/web/src/page/settings/ai/view.ts": 2, - "apps/web/src/page/settings/audit-log/view.ts": 3, "apps/web/src/page/settings/automation/view.ts": 1, "apps/web/src/page/settings/billing/details/view.ts": 1, "apps/web/src/page/settings/billing/view-account.ts": 3, From 7312a1bdea59394059680b538ea2ddfec0e49a67 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:45:06 +0100 Subject: [PATCH 04/11] chore(ui): index the day-label page kit module --- packages/views/src/ui/INDEX.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index efea20d0f7..5fd4ee5df8 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -188,6 +188,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/custom-range-view.ts` `view`: The custom range picker's DOM: a two-month range calendar (en-US, rdp-* classes, Maple's `calendarClassNames`), then the Start/End time fields and the actions. - `apps/web/src/page/kit/custom-range.ts` `readToday`, `parseTimeInput`, `addToRange`, `appliedRange` (stateful: Model, init, update): The custom range picker: a two-month range calendar and two time inputs in the selected zone's wall clock. - `apps/web/src/page/kit/dashboard-page.ts` `pageBody`, `filterRail`, `pageContent`, `pageHeader`, `pageScrollArea`, `dashboardPage`: The dashboard page below the shell's top bar: `PageLayout.Body` with its filter rail, content column, sticky header and scroll area. +- `apps/web/src/page/kit/day-label.ts` `dayKey`, `dayLabel`: Calendar days in a time zone, for lists grouped by day ("Today", "Yesterday", "Mon 2 Nov"). - `apps/web/src/page/kit/destination-picker.ts` `destinationPicker`, `pickedDestination`: `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add destination" menu of the rest; the owner keeps the menu's Model and the chosen ids. - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: `DetailHeader`'s title side and `DetailHeaderSkeleton`. - `apps/web/src/page/kit/form-section.ts` `formSection`, `formScrollArea`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. From 44f4017370d0fb5b9e7c657312c116db11278839 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:51:07 +0100 Subject: [PATCH 05/11] fix(audit-log): mute reads only in the All view In Data access every row is a read, so muting allowed ones dimmed the whole list. The tone now depends on the view as well as the entry. --- apps/web/src/page/settings/audit-log/view.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 3d5081bbd7..5251b70157 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -417,6 +417,8 @@ const withMemberName = ( } const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs): Html => { + // Muting reads only helps where they sit beside changes; in Data access every row is one. + const isMixedView = auditLogKindOf(parseAuditLogSearch(inputs.search)) === "all" const isExpanded = model.expanded.includes(entry.id) const detailId = `audit-entry-${entry.id}` const isDenied = entry.outcome === "denied" @@ -472,7 +474,7 @@ const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs vstack( h, { shrink: true }, - actionLabel(h, entry.action, isQuietAccess(entry)), + actionLabel(h, entry.action, isMixedView && isQuietAccess(entry)), ), ...(isDenied ? [h.span([h.Class("@md:hidden")], [deniedBadge(h)])] : []), ]), From 789d476600f09a6488f0b942920c1dcc776dd923 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:53:15 +0100 Subject: [PATCH 06/11] fix(audit-log): a shared entry link opens its entry however busy that minute was The link listed the minute around the entry, newest first, so 50 newer entries pushed it to page 2 where it never opened. The window now ends at the entry, which therefore heads the first page, and a later page holding it reveals it too. --- .../audit-log/audit-log.scene.test.ts | 40 +++++++++++++++++++ .../web/src/page/settings/audit-log/update.ts | 3 +- .../page/settings/audit-log/view-detail.ts | 7 +++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts index 555ded8600..cb3dbfedd8 100644 --- a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts @@ -315,6 +315,46 @@ describe("audit log tab", () => { expect(Arr.map(loaded.commands ?? [], ({ name }) => name)).toEqual(["ScrollToAuditEntry"]) }) + test("a shared entry link still opens its entry when it arrives on a later page", () => { + const target = AUDIT_ENTRIES[5] + if (target === undefined) throw new Error("fixture") + const linked = { + tab: "audit-log", + entry: encodePublicId(PublicIdPrefixes.auditLogEntry, target.id), + } as const + const entered = init(linked).model + const first = update( + entered, + Message.CompletedFetchPage({ + request: entered.request, + resolvedSince: null, + result: Result.succeed({ + data: AUDIT_ENTRIES.slice(0, 3), + has_more: true, + next_cursor: "cursor_2", + }), + }), + linked, + ).model + expect(first.expanded).toEqual([]) + const asked = update(first, Message.ClickedLoadMore(), linked).model + const second = update( + asked, + Message.CompletedFetchPage({ + request: asked.request, + resolvedSince: null, + result: Result.succeed({ + data: AUDIT_ENTRIES.slice(3), + has_more: false, + next_cursor: null, + }), + }), + linked, + ) + expect(second.model.expanded).toEqual([target.id]) + expect(Arr.map(second.commands ?? [], ({ name }) => name)).toEqual(["ScrollToAuditEntry"]) + }) + test("the Origin section names the country an entry came from", () => { const target = AUDIT_ENTRIES[4] if (target === undefined) throw new Error("fixture") diff --git a/apps/web/src/page/settings/audit-log/update.ts b/apps/web/src/page/settings/audit-log/update.ts index 841b426e23..208e80361c 100644 --- a/apps/web/src/page/settings/audit-log/update.ts +++ b/apps/web/src/page/settings/audit-log/update.ts @@ -353,9 +353,8 @@ export const update = (model: Model, message: Message, search: SettingsSearch): const linked = linkedAuditEntryId(search) return Result.match(result, { onSuccess: (page) => { - // A shared link opens on its entry, once, when the first page holds it. + // A shared link opens on its entry, once, on whichever page holds it. const reveal = - request.cursor === undefined && linked !== null && !model.expanded.includes(linked) && Arr.some(page.data, (entry) => entry.id === linked) diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index 174bbb919e..540d9a9442 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -216,7 +216,10 @@ const isLabelledKey = (key: string): key is keyof typeof METADATA_LABELS => const metadataLabel = (key: string): string => isLabelledKey(key) ? METADATA_LABELS[key] : Str.capitalize(Str.replaceAll("_", " ")(key)) -/** The audit log filtered to the minute around one entry, with that entry open. */ +/** + * The audit log ending at one entry, with that entry open. Newest first, so the entry heads the + * first page however busy the minute was, and the minute before it follows as context. + */ export const entryHref = (entry: V2AuditLogEntry): string => { const at = DateTime.toEpochMillis(DateTime.makeUnsafe(entry.occurred_at)) return hrefOf( @@ -225,7 +228,7 @@ export const entryHref = (entry: V2AuditLogEntry): string => { tab: "audit-log", kind: "all", since: new Date(at - 60_000).toISOString(), - until: new Date(at + 60_000).toISOString(), + until: entry.occurred_at, entry: encodePublicId(PublicIdPrefixes.auditLogEntry, entry.id), }, }), From aba17d0041ebc12d048a045096601eedad485d53 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:59:49 +0100 Subject: [PATCH 07/11] refactor(audit-log): one access-action list for the server and the web The web muted reads with its own copy of the server's access suffixes, so a new access action would be filtered but not muted. The list and an isAuditAccessAction helper move to @maple/domain/audit-actions; the backend re-exports it for existing importers. --- apps/web/src/lab/audit-log-lab.ts | 5 ++--- apps/web/src/page/settings/audit-log/view.ts | 5 ++--- .../backend/src/services/audit/AuditLogService.ts | 14 ++++---------- packages/domain/src/audit-actions.ts | 13 ++++++++++++- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lab/audit-log-lab.ts b/apps/web/src/lab/audit-log-lab.ts index 5a70c0c638..de5552d219 100644 --- a/apps/web/src/lab/audit-log-lab.ts +++ b/apps/web/src/lab/audit-log-lab.ts @@ -1,6 +1,7 @@ +import { isAuditAccessAction as isAccess } from "@maple/domain/audit-actions" import type { V2AuditLogEntry } from "@maple/domain/http/v2" import { toLoadFailure } from "@maple/views/data/load-failure" -import { Array as Arr, Effect, Option, Record as Rec, Result, Schema, String as Str } from "effect" +import { Array as Arr, Effect, Option, Record as Rec, Result, Schema } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" @@ -50,8 +51,6 @@ type Return = Update.Return type AuditCommand = Command.Command const TIME_ZONE = "Europe/Berlin" -const ACCESS_SUFFIXES = [".read", ".called", ".sql_executed", ".request"] -const isAccess = (action: string) => Arr.some(ACCESS_SUFFIXES, (suffix) => Str.endsWith(suffix)(action)) /** The fixtures the API would answer `request` with. */ const matching = (request: AuditModel.PageRequest): ReadonlyArray => diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 5251b70157..35d2865786 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -3,6 +3,7 @@ import { AuditResourceLabels, AuditResources, auditResourceLabel, + isAuditAccessAction, } from "@maple/domain/audit-actions" import type { AuditActorType, AuditLogSource } from "@maple/domain/http" import { encodePublicId, PublicIdPrefixes, type V2ApiKey, type V2AuditLogEntry } from "@maple/domain/http/v2" @@ -363,11 +364,9 @@ const actorCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { ) } -const ACCESS_SUFFIXES = [".read", ".called", ".sql_executed", ".request"] - /** A read, tool call, SQL run or API request that went through: background next to a change. */ const isQuietAccess = (entry: V2AuditLogEntry): boolean => - entry.outcome === "allowed" && Arr.some(ACCESS_SUFFIXES, (suffix) => Str.endsWith(suffix)(entry.action)) + entry.outcome === "allowed" && isAuditAccessAction(entry.action) const actionLabel = (h: H, action: string, isQuiet: boolean): ReadonlyArray => { const label = auditActionLabel(action) diff --git a/packages/backend/src/services/audit/AuditLogService.ts b/packages/backend/src/services/audit/AuditLogService.ts index 8afac5f199..c01831ae0a 100644 --- a/packages/backend/src/services/audit/AuditLogService.ts +++ b/packages/backend/src/services/audit/AuditLogService.ts @@ -10,6 +10,7 @@ import { runAfterResponse } from "@maple/backend/platform/after-response" import { AuditEventsQueueProducer } from "@maple/backend/platform/bindings" import { systemTenant } from "@maple/backend/services/alerts/system-tenant" import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" +import { AUDIT_ACCESS_ACTION_SUFFIXES, isAuditAccessAction } from "@maple/domain/audit-actions" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" import { type AuditAction, auditResourceFields, type AuditResourceIdOption } from "./audit-actions" import { @@ -102,11 +103,7 @@ export interface AuditLogListFilters { readonly offset: number } -/** - * Actions that record access rather than a change: reads, tool calls, SQL runs - * and API requests. A heavy agent user writes hundreds of these a day. - */ -export const AUDIT_ACCESS_ACTION_SUFFIXES = [".read", ".called", ".sql_executed", ".request"] as const +export { AUDIT_ACCESS_ACTION_SUFFIXES } export interface AuditLogServiceApi { /** @@ -180,9 +177,6 @@ const neverFail = ), ) -const isAccessAction = (action: string) => - Arr.some(AUDIT_ACCESS_ACTION_SUFFIXES, (suffix) => Str.endsWith(suffix)(action)) - /** The suffix exclusion a listing applies: the caller's own, or the one `actionKind` implies. */ const actionSuffixOpts = (filters: AuditLogListFilters) => { if (filters.actionKind === "changes") @@ -404,9 +398,9 @@ export function makeMemoryAuditLog(): AuditLogServiceApi { Str.endsWith(suffix)(entry.action), ) && (filters.actionKind !== "changes" || - !isAccessAction(entry.action) || + !isAuditAccessAction(entry.action) || entry.outcome === "denied") && - (filters.actionKind !== "access" || isAccessAction(entry.action)), + (filters.actionKind !== "access" || isAuditAccessAction(entry.action)), ) .sort((a, b) => { const byTime = b.occurredAt.getTime() - a.occurredAt.getTime() diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index f82c646409..96c8a84dab 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -1,6 +1,6 @@ import { ErrorIssueEventType } from "./http/errors" import { type PublicIdPrefix, PublicIdPrefixes } from "./http/v2/public-id" -import { Array as Arr, Record as Rec } from "effect" +import { Array as Arr, Record as Rec, String as Str } from "effect" /** * Every audited action in Maple, grouped by the resource it acts on. @@ -416,6 +416,17 @@ const isAuditResourceType = (type: string): type is AuditResourceType => Rec.has(AuditResourceLabels, type) /** Label for an action string read off a row; null for one this build does not know. */ +/** + * Actions that record access rather than a change: reads, tool calls, SQL runs + * and API requests. A heavy agent user writes hundreds of these a day. The + * server filters by them and the web mutes them, so both read this one list. + */ +export const AUDIT_ACCESS_ACTION_SUFFIXES = [".read", ".called", ".sql_executed", ".request"] as const + +/** True for an access action (see {@link AUDIT_ACCESS_ACTION_SUFFIXES}). */ +export const isAuditAccessAction = (action: string): boolean => + Arr.some(AUDIT_ACCESS_ACTION_SUFFIXES, (suffix) => Str.endsWith(suffix)(action)) + export const auditActionLabel = (action: string): string | null => isAuditAction(action) ? AuditActionLabels[action] : null From 14c88ee8543e8893183b244ba51f2505f666e996 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:00:41 +0100 Subject: [PATCH 08/11] test(audit-log): a filter change drops the shared entry link --- .../page/settings/audit-log/audit-log.scene.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts index cb3dbfedd8..02136f4ef1 100644 --- a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts @@ -355,6 +355,17 @@ describe("audit log tab", () => { expect(Arr.map(second.commands ?? [], ({ name }) => name)).toEqual(["ScrollToAuditEntry"]) }) + test("a filter change leaves a shared entry link behind", () => { + const linked = { tab: "audit-log", entry: "alog_gQUY7khzfVuTjEMzpTTstW" } as const + const step = update(init(linked).model, Message.ClickedOutcome({ value: "denied" }), linked) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&outcome=denied", + isReplace: true, + }), + ) + }) + test("the Origin section names the country an entry came from", () => { const target = AUDIT_ENTRIES[4] if (target === undefined) throw new Error("fixture") From 9eea2ea32cdc86123f1ca8e5ccf11c0ada6bdec3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 01:53:42 +0100 Subject: [PATCH 09/11] docs(audit-log): the action label's doc comment sits on its function again --- packages/domain/src/audit-actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index 96c8a84dab..91b4990c48 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -415,7 +415,6 @@ const isAuditAction = (action: string): action is AuditAction => const isAuditResourceType = (type: string): type is AuditResourceType => Rec.has(AuditResourceLabels, type) -/** Label for an action string read off a row; null for one this build does not know. */ /** * Actions that record access rather than a change: reads, tool calls, SQL runs * and API requests. A heavy agent user writes hundreds of these a day. The @@ -427,6 +426,7 @@ export const AUDIT_ACCESS_ACTION_SUFFIXES = [".read", ".called", ".sql_executed" export const isAuditAccessAction = (action: string): boolean => Arr.some(AUDIT_ACCESS_ACTION_SUFFIXES, (suffix) => Str.endsWith(suffix)(action)) +/** Label for an action string read off a row; null for one this build does not know. */ export const auditActionLabel = (action: string): string | null => isAuditAction(action) ? AuditActionLabels[action] : null From 81ba0ce70da21c5ca1213fe1e589794851a552db Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 01:57:24 +0100 Subject: [PATCH 10/11] fix(audit-log): an entry Maple itself made still names its surface The Origin section was skipped when an entry had no IP, country or request id, which also hid the surface it always has. Adds a test for that, and one for the window an entry's link opens. --- .../audit-log/audit-log.scene.test.ts | 36 ++++++++++++++++--- .../page/settings/audit-log/view-detail.ts | 2 +- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts index 02136f4ef1..603e7248db 100644 --- a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts @@ -1,4 +1,4 @@ -import { Result, Array as Arr } from "effect" +import { Array as Arr, Option, Record as Rec, Result, Schema } from "effect" import { encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" import { AUDIT_ENTRIES, AUDIT_MEMBERS, AUDIT_NOW } from "../../../lab/audit-log-fixture" @@ -8,6 +8,8 @@ import * as CustomRange from "../../kit/custom-range" import { Message } from "./model" import { init, revisit, searchChanged, update } from "./update" import { view } from "./view" +import { entryHref } from "./view-detail" +import { SettingsSearch } from "../../../route/search" const page = { data: [], has_more: true, next_cursor: "cursor_2" } @@ -237,9 +239,7 @@ describe("audit log tab", () => { describe("an entry's detail", () => { const search = { tab: "audit-log" } as const - const loadedWith = ( - searchFor: typeof search | { readonly tab: "audit-log"; readonly entry: string }, - ) => { + const loadedWith = (searchFor: typeof SettingsSearch.Type) => { const entered = init(searchFor).model const withMembers = update( entered, @@ -366,6 +366,34 @@ describe("audit log tab", () => { ) }) + test("an entry's link lists the minute that ends at the entry, and opens it", () => { + const target = AUDIT_ENTRIES[5] + if (target === undefined) throw new Error("fixture") + const linked = Schema.decodeSync(SettingsSearch)( + Rec.fromEntries([...new URL(entryHref(target), "https://maple.test").searchParams.entries()]), + ) + // The fixture's sixth entry happened 47 minutes before `AUDIT_NOW`. + expect(linked).toEqual({ + tab: "audit-log", + kind: "all", + since: new Date(AUDIT_NOW - 48 * 60_000).toISOString(), + until: new Date(AUDIT_NOW - 47 * 60_000).toISOString(), + entry: encodePublicId(PublicIdPrefixes.auditLogEntry, target.id), + }) + expect(loadedWith(linked).model.expanded).toEqual([target.id]) + }) + + test("an entry Maple itself made still names its surface", () => { + const target = Arr.findFirst(AUDIT_ENTRIES, (entry) => entry.source === "system") + if (Option.isNone(target)) throw new Error("fixture") + const opened = update( + loadedWith(search).model, + Message.ToggledEntry({ id: target.value.id }), + search, + ).model + scene(sceneOf(search), given(opened), expectView(text("Surface")).toExist()) + }) + test("the Origin section names the country an entry came from", () => { const target = AUDIT_ENTRIES[4] if (target === undefined) throw new Error("fixture") diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index 540d9a9442..01b479c786 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -235,8 +235,8 @@ export const entryHref = (entry: V2AuditLogEntry): string => { ) } +// Every entry has a surface, so the section shows even when Maple itself acted and nothing else is known. const originSection = (h: H, model: Model, entry: V2AuditLogEntry): ReadonlyArray => { - if (entry.origin_ip === null && entry.origin_country === null && entry.request_id === null) return [] return [ h.section( [h.Class("space-y-1.5")], From 7fbd8427ba5ab2b4cd45e17ab86f7a81315f46fc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 11:02:08 +0100 Subject: [PATCH 11/11] fix(audit-log): Filter from a shared entry link leaves that link's one-minute window An entry link lists the minute ending at its entry. A pivot kept that custom range, so "every entry with this actor" listed only that minute. --- .../audit-log/audit-log.scene.test.ts | 19 +++++++++++++++++++ .../web/src/page/settings/audit-log/update.ts | 10 +++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts index 603e7248db..0ab288c304 100644 --- a/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts +++ b/apps/web/src/page/settings/audit-log/audit-log.scene.test.ts @@ -303,6 +303,25 @@ describe("audit log tab", () => { ) }) + test("Filter from a shared entry link leaves that link's one-minute window", () => { + const target = AUDIT_ENTRIES[5] + if (target === undefined) throw new Error("fixture") + const linked = Schema.decodeSync(SettingsSearch)( + Rec.fromEntries([...new URL(entryHref(target), "https://maple.test").searchParams.entries()]), + ) + const step = update( + loadedWith(linked).model, + Message.ClickedPivot({ key: "actorId", value: "key_aXwpxqBkqtYwtBtmsGbR41" }), + linked, + ) + expect(step.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: "/settings?tab=audit-log&actor=key_aXwpxqBkqtYwtBtmsGbR41&kind=all", + isReplace: true, + }), + ) + }) + test("a shared entry link opens that entry and scrolls to it", () => { const target = AUDIT_ENTRIES[2] if (target === undefined) throw new Error("fixture") diff --git a/apps/web/src/page/settings/audit-log/update.ts b/apps/web/src/page/settings/audit-log/update.ts index 208e80361c..ef9d3628c4 100644 --- a/apps/web/src/page/settings/audit-log/update.ts +++ b/apps/web/src/page/settings/audit-log/update.ts @@ -475,7 +475,15 @@ export const update = (model: Model, message: Message, search: SettingsSearch): }), }), RemovedFilter: ({ key }) => navigateFilters(model, search, withoutFilter(filters, key)), - ClickedPivot: ({ key, value }) => navigateFilters(model, search, pivoted(filters, key, value)), + ClickedPivot: ({ key, value }) => { + const next = pivoted(filters, key, value) + // An entry link's window is that entry's minute: "every entry with this" must not stay inside it. + return navigateFilters( + model, + search, + search.entry === undefined ? next : { ...next, range: { kind: "all" } }, + ) + }, CompletedScrollToEntry: () => ({ model }), ClickedCustomRange: ({ timeZone }) => ({ model, commands: [ResolveAuditCustomToday({ timeZone })] }), ResolvedCustomToday: ({ timeZone, today, lastDay }) => {