Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9c7a8ff
feat(audit-log): split changes from data access, add resource/action/…
Makisuo Nov 9, 2026
add9406
feat(audit-log): filter from an entry's detail, name members, show wh…
Makisuo Nov 9, 2026
8b8805a
feat(audit-log): day headings, a red edge on refusals, quieter reads
Makisuo Nov 9, 2026
7312a1b
chore(ui): index the day-label page kit module
Makisuo Nov 9, 2026
44f4017
fix(audit-log): mute reads only in the All view
Makisuo Nov 9, 2026
789d476
fix(audit-log): a shared entry link opens its entry however busy that…
Makisuo Nov 9, 2026
391e40d
Merge branch 'audit-log-ui/b-detail-pivots' into audit-log-ui/c-rows-…
Makisuo Nov 9, 2026
aba17d0
refactor(audit-log): one access-action list for the server and the web
Makisuo Nov 9, 2026
14c88ee
test(audit-log): a filter change drops the shared entry link
Makisuo Nov 9, 2026
bd28017
Merge branch 'audit-log-ui/b-detail-pivots' into audit-log-ui/c-rows-…
Makisuo Nov 9, 2026
9833c48
Merge remote-tracking branch 'origin/main' into audit-log-ui/a-views-…
Makisuo Nov 10, 2026
b491fc3
Merge branch 'audit-log-ui/b-detail-pivots' into audit-log-ui/c-rows-…
Makisuo Nov 10, 2026
924400a
Merge branch 'audit-log-ui/a-views-filters' into audit-log-ui/b-detai…
Makisuo Nov 10, 2026
9eea2ea
docs(audit-log): the action label's doc comment sits on its function …
Makisuo Nov 10, 2026
81ba0ce
fix(audit-log): an entry Maple itself made still names its surface
Makisuo Nov 10, 2026
0b85f10
Merge branch 'audit-log-ui/b-detail-pivots' into audit-log-ui/c-rows-…
Makisuo Nov 10, 2026
6b46d45
Merge branch 'main' into audit-log-ui/b-detail-pivots
Makisuo Nov 10, 2026
7fbd842
fix(audit-log): Filter from a shared entry link leaves that link's on…
Makisuo Nov 10, 2026
669d9ed
Merge branch 'audit-log-ui/b-detail-pivots' into audit-log-ui/c-rows-…
Makisuo Nov 10, 2026
33f2691
Merge branch 'main' into audit-log-ui/c-rows-layout
Makisuo Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/src/lab/audit-log-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/lab/audit-log-lab.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -50,8 +51,6 @@ type Return = Update.Return<Model, Message, Resources>
type AuditCommand = Command.Command<AuditModel.Message, never, Resources>

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<V2AuditLogEntry> =>
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/page/kit/day-label.test.ts
Original file line number Diff line number Diff line change
@@ -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"),
)
})
})
40 changes: 40 additions & 0 deletions apps/web/src/page/kit/day-label.ts
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 9 additions & 0 deletions apps/web/src/page/settings/audit-log/audit-log.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,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("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")
Expand Down
76 changes: 65 additions & 11 deletions apps/web/src/page/settings/audit-log/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -50,7 +51,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<Message>
Expand Down Expand Up @@ -102,7 +104,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",
}

Expand Down Expand Up @@ -337,7 +339,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
? []
Expand All @@ -358,11 +364,18 @@ const actorCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => {
)
}

const actionLabel = (h: H, action: string): ReadonlyArray<Html> => {
/** 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" && isAuditAccessAction(entry.action)

const actionLabel = (h: H, action: string, isQuiet: boolean): ReadonlyArray<Html> => {
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,
Expand All @@ -383,10 +396,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])],
)
}

Expand All @@ -403,6 +416,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"
Expand All @@ -415,8 +430,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(
Expand Down Expand Up @@ -452,7 +470,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, isMixedView && isQuietAccess(entry)),
),
...(isDenied ? [h.span([h.Class("@md:hidden")], [deniedBadge(h)])] : []),
]),
...(isDenied && entry.denial_reason !== null
Expand Down Expand Up @@ -530,10 +552,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<V2AuditLogEntry>,
inputs: ViewInputs,
): ReadonlyArray<Html> => {
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<Html> => {
const memberName = (userId: string) =>
Expand Down
19 changes: 1 addition & 18 deletions apps/web/src/page/users/view-detail.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
33 changes: 2 additions & 31 deletions apps/web/src/page/users/view-detail.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { dayKey, dayLabel } from "../kit/day-label"
import {
countLabel,
EMPTY_VALUE,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down
14 changes: 4 additions & 10 deletions packages/backend/src/services/audit/AuditLogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading