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/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/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 0ab288c304..4a58e377e5 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 @@ -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") diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 22705fb914..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" @@ -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 @@ -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", } @@ -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 ? [] @@ -358,11 +364,18 @@ const actorCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { ) } -const actionLabel = (h: H, action: string): ReadonlyArray => { +/** 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 => { 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 +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])], ) } @@ -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" @@ -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( @@ -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 @@ -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, + 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/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..91b4990c48 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. @@ -415,6 +415,17 @@ const isAuditAction = (action: string): action is AuditAction => const isAuditResourceType = (type: string): type is AuditResourceType => Rec.has(AuditResourceLabels, type) +/** + * 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)) + /** 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 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. diff --git a/scripts/oxlint-plugins/ui-kit-baseline.json b/scripts/oxlint-plugins/ui-kit-baseline.json index 07a9602f2c..216075f8a5 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,