diff --git a/apps/web/src/lab/audit-log-lab.ts b/apps/web/src/lab/audit-log-lab.ts index de5552d219..96414b304c 100644 --- a/apps/web/src/lab/audit-log-lab.ts +++ b/apps/web/src/lab/audit-log-lab.ts @@ -110,10 +110,13 @@ const answer = (state: LabState, model: AuditModel.Model, commands: ReadonlyArra ).model } -/** The kit's own commands and the custom range's clock read; every API read is answered above. */ +/** The kit's own commands and the custom range's clock read and focus; every API read is answered above. */ const local = (commands: ReadonlyArray | undefined) => [ ...keepLocal(commands), - ...Arr.filter(commands ?? [], (command) => command.name === "ResolveAuditCustomToday"), + ...Arr.filter( + commands ?? [], + (command) => command.name === "ResolveAuditCustomToday" || command.name === "FocusAuditDateRange", + ), ] const lift = (commands: ReadonlyArray) => 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 4a58e377e5..ecaedb67c7 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 @@ -237,6 +237,20 @@ describe("audit log tab", () => { expect(picked.model.customRange).toBeNull() }) + test("opening the custom range moves focus into the popover, so Escape still closes it", () => { + 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, + ) + expect(Arr.map(opened.commands ?? [], ({ name }) => name)).toEqual(["FocusAuditDateRange"]) + }) + describe("an entry's detail", () => { const search = { tab: "audit-log" } as const const loadedWith = (searchFor: typeof SettingsSearch.Type) => { @@ -422,6 +436,31 @@ describe("audit log tab", () => { scene(sceneOf(search), given(opened), expectView(text("Surface")).toExist()) }) + test("an open entry offers a filter beside its actor, action and resource", () => { + const target = AUDIT_ENTRIES[2] + 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(role("button", { name: "Show every entry with this actor" })).toExist(), + expectView(role("button", { name: "Show every entry with this action" })).toExist(), + expectView(role("button", { name: "Show every entry with this resource" })).toExist(), + ) + }) + + test("a row names the kind of actor behind an API key", () => { + scene( + sceneOf(search), + given(loadedWith(search).model), + expectView(role("img", { name: "API key" })).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/model.ts b/apps/web/src/page/settings/audit-log/model.ts index c9aa5cf8b9..077764c4e8 100644 --- a/apps/web/src/page/settings/audit-log/model.ts +++ b/apps/web/src/page/settings/audit-log/model.ts @@ -119,5 +119,6 @@ export const Message = defineMessageUnion({ /** "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: {}, + CompletedFocusDateRange: {}, }) 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 ef9d3628c4..69fc0ef075 100644 --- a/apps/web/src/page/settings/audit-log/update.ts +++ b/apps/web/src/page/settings/audit-log/update.ts @@ -15,7 +15,7 @@ import { 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 { Command, Dom } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import { isClerkAuthEnabled } from "../../../lib/services/common/auth-mode" import { MapleApiV2Client } from "../../../platform/api-clients" @@ -24,6 +24,7 @@ import { memberDisplayName } from "../kit/members" import { AppRoute, hrefOf } from "../../../route" import type { SettingsSearch as SettingsSearchSchema } from "../../../route/search" import * as CopyButton from "@maple/views/ui/copy-button" +import { popupId } from "@maple/views/ui/overlay/popup-state" import * as Popover from "@maple/views/ui/popover" import * as Select from "@maple/views/ui/select" import { PageOutMessage, type PageReturn } from "../../contract" @@ -324,6 +325,17 @@ const ScrollToAuditEntry = Command.define("ScrollToAuditEntry", { }), }) +/** Focus back inside the popover once the calendar replaces the pressed preset, so Escape still closes it. */ +const FocusAuditDateRange = Command.define("FocusAuditDateRange", { + args: { id: Schema.String }, + messages: [Message.CompletedFocusDateRange], + execute: ({ id }) => + Dom.focus(`#${CSS.escape(popupId(id))}`).pipe( + Effect.ignore, + Effect.as(Message.CompletedFocusDateRange()), + ), +}) + /** 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) { @@ -485,6 +497,7 @@ export const update = (model: Model, message: Message, search: SettingsSearch): ) }, CompletedScrollToEntry: () => ({ model }), + CompletedFocusDateRange: () => ({ model }), ClickedCustomRange: ({ timeZone }) => ({ model, commands: [ResolveAuditCustomToday({ timeZone })] }), ResolvedCustomToday: ({ timeZone, today, lastDay }) => { const range = filters.range @@ -499,6 +512,7 @@ export const update = (model: Model, message: Message, search: SettingsSearch): lastDay, }), ), + commands: [FocusAuditDateRange({ id: model.dateRange.id })], } }, GotCustomRangeMessage: ({ message: child }) => { 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 5490506e8d..e57a9395f9 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -24,7 +24,7 @@ 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" +import { iconButton } from "@maple/views/ui/icon-button" // `AuditLogDetail` and its parts. @@ -82,28 +82,43 @@ const changeValue = (h: H, value: unknown): Html => { return typography(h, { tone: "subtle", className: "italic" }, [Str.slice(1, -1)(value)]) if (value === undefined || value === null || value === "") return typography(h, { tone: "subtle" }, [EMPTY_VALUE]) - return h.span([h.Class("break-all")], [formatScalar(value)]) + return h.span([h.Class("wrap-anywhere")], [formatScalar(value)]) } -const identifier = (h: H, model: Model, value: string, label: string): Html => { +/** An id with its actions (copy, and "Filter" when given) as one group that wraps together. */ +const identifier = (h: H, model: Model, value: string, label: string, action?: Html): 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", size: "2xs", tone: "foreground", className: "truncate" }, [value]), - CopyButton.view(h, { - model: model.copies[key] ?? CopyButton.init(), - toParentMessage: (message) => Message.GotCopyMessage({ key, message }), - value, - label, - iconSize: 12, - className: "size-5", - }), - ]) + // On touch a button answers a 44px square around itself: the wider gap keeps the filter's + // square off the copy glyph. + return hstack( + h, + { gap: "1", align: "center", inline: true, as: "span", className: "max-w-full pointer-coarse:gap-5" }, + [ + 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 }), + value, + label, + // As tall as the line of text, so a value with actions keeps its label's baseline. + size: "icon-2xs", + iconSize: 12, + }), + ...(action === undefined ? [] : [action]), + ], + ) } 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"]), + tableHead(h, ["Before"], "w-1/2"), + tableHead(h, ["After"], "w-1/2"), + ]), ]), tableBody( h, @@ -131,17 +146,17 @@ const metadataList = (h: H, metadata: Readonly>): Readon { layout: "grid" }, Arr.map(keys, (key) => { const block = metadataBlock(metadata[key]) - return keyValue(h, { wrap: true, label: metadataLabel(key) }, [ + return field(h, { label: metadataLabel(key) }, [ block !== null ? h.pre( [ h.Class( - "bg-surface-well max-h-64 overflow-auto rounded-md border px-2.5 py-2 font-mono text-2xs leading-relaxed whitespace-pre-wrap break-all", + "bg-surface-well max-h-64 overflow-auto rounded-md border px-2.5 py-2 font-mono text-2xs leading-relaxed whitespace-pre-wrap wrap-anywhere", ), ], [block], ) - : typography(h, { size: "2xs", mono: true, className: "break-all" }, [ + : typography(h, { size: "2xs", mono: true, className: "wrap-anywhere" }, [ changeValue(h, metadata[key]), ]), ]) @@ -150,6 +165,10 @@ const metadataList = (h: H, metadata: Readonly>): Readon ] } +/** A labelled value that may wrap, starting on its label's line however tall its neighbour is. */ +const field = (h: H, options: Readonly<{ label: string }>, children: ReadonlyArray): Html => + keyValue(h, { wrap: true, label: options.label, valueClassName: "items-start" }, children) + /** 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 @@ -161,14 +180,17 @@ export const memberName = (model: Model, userId: string | null): string | null = ), ) -/** "Filter" beside a value: lists every entry with that value instead. */ +/** The filter glyph 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", - icon: "FilterIcon", - onClick: Message.ClickedPivot({ key, value }), - }) + iconButton( + h, + { + label: `Show every entry with this ${what}`, + size: "icon-2xs", + attributes: [h.OnClick(Message.ClickedPivot({ key, value }))], + }, + [icon(h, "FilterIcon")], + ) const regionNames = new Intl.DisplayNames(["en"], { type: "region" }) @@ -246,7 +268,7 @@ const originSection = (h: H, model: Model, entry: V2AuditLogEntry): ReadonlyArra [ eyebrow(h, { variant: "mono", as: "h4", icon: "GlobeIcon" }, ["Origin"]), keyValueList(h, { layout: "grid", className: DETAIL_GRID }, [ - keyValue(h, { wrap: true, label: "Surface" }, [ + field(h, { label: "Surface" }, [ valueRow(h, [ icon(h, SOURCE_ICONS[entry.source], { size: 13, @@ -258,28 +280,27 @@ const originSection = (h: H, model: Model, entry: V2AuditLogEntry): ReadonlyArra ...(entry.origin_ip === null ? [] : [ - keyValue(h, { wrap: true, label: "IP address" }, [ + field(h, { label: "IP address" }, [ identifier(h, model, entry.origin_ip, "IP address"), ]), ]), ...(entry.origin_country === null ? [] : [ - keyValue(h, { wrap: true, label: "Country" }, [ + field(h, { label: "Country" }, [ h.span([], [countryText(entry.origin_country)]), ]), ]), ...(entry.request_id === null ? [] : [ - keyValue(h, { wrap: true, label: "Request" }, [ - hstack( + field(h, { label: "Request" }, [ + identifier( 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"), - ], + model, + entry.request_id, + "Request id", + pivot(h, "requestId", entry.request_id, "request"), ), ]), ]), @@ -313,41 +334,37 @@ export const auditDetail = ( [h.Class("border-border-subtle space-y-4 border-t px-4 py-3 @md:pl-11")], [ 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" }, [ + field(h, { label: "Occurred" }, [full(entry.occurred_at)]), + field(h, { label: "Recorded" }, [full(entry.recorded_at)]), + field(h, { label: "Actor" }, [ valueRow(h, [ actorBadge(h, entry.actor_type), ...(actorName === null ? [] : [h.span([], [actorName])]), ...(entry.actor_id === null ? [] : [ - identifier(h, model, entry.actor_id, "Actor id"), - pivot(h, "actorId", entry.actor_id, "actor"), + identifier( + h, + model, + entry.actor_id, + "Actor id", + pivot(h, "actorId", entry.actor_id, "actor"), + ), ]), ]), ]), - keyValue(h, { wrap: true, label: "Action" }, [ + field(h, { label: "Action" }, [ valueRow(h, [ ...(actionText === null ? [] : [h.span([], [actionText])]), - inlineCode(h, { variant: "plain", size: "2xs", tone: "foreground" }, [entry.action]), - entry.outcome === "denied" - ? deniedBadge(h) - : badge(h, { variant: "ok", size: "sm" }, [ - icon(h, OUTCOME_ICONS.allowed), - "Allowed", - ]), - ...(actionText === null ? [] : [pivot(h, "action", entry.action, "action")]), - ]), - ...(entry.denial_reason === null - ? [] - : [ - typography(h, { tone: "muted", as: "p", className: "mt-0.5" }, [ - entry.denial_reason, - ]), + hstack(h, { gap: "1", align: "center", inline: true, as: "span" }, [ + inlineCode(h, { variant: "plain", size: "2xs", tone: "foreground" }, [ + entry.action, ]), + ...(actionText === null ? [] : [pivot(h, "action", entry.action, "action")]), + ]), + ]), ]), - keyValue(h, { wrap: true, label: "Resource" }, [ + field(h, { label: "Resource" }, [ entry.resource_type === null && entry.resource_id === null ? typography(h, { tone: "subtle" }, [EMPTY_VALUE]) : valueRow(h, [ @@ -365,23 +382,47 @@ export const auditDetail = ( ...(entry.resource_id === null ? [] : [ - identifier(h, model, entry.resource_id, "Resource id"), - pivot(h, "resourceId", entry.resource_id, "resource"), + identifier( + h, + model, + entry.resource_id, + "Resource id", + pivot(h, "resourceId", entry.resource_id, "resource"), + ), ]), ]), ]), + // Its own row, as in the list: whether it went through, and why not when it did not. + field(h, { label: "Outcome" }, [ + valueRow(h, [ + entry.outcome === "denied" + ? deniedBadge(h) + : badge(h, { variant: "ok", size: "sm" }, [ + icon(h, OUTCOME_ICONS.allowed), + "Allowed", + ]), + ...(entry.denial_reason === null + ? [] + : [typography(h, { tone: "muted" }, [entry.denial_reason])]), + ]), + ]), ...(entry.affected_user === null ? [] : [ - keyValue(h, { wrap: true, label: "Affected user" }, [ + field(h, { 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"), + identifier( + h, + model, + entry.affected_user, + "Affected user id", + pivot(h, "affectedUserId", entry.affected_user, "affected user"), + ), ]), ]), ]), - keyValue(h, { wrap: true, label: "Entry" }, [ + field(h, { label: "Entry" }, [ valueRow(h, [ identifier( h, @@ -389,7 +430,7 @@ export const auditDetail = ( encodePublicId(PublicIdPrefixes.auditLogEntry, entry.id), "Entry id", ), - textLink(h, { href: entryHref(entry) }, [ + textLink(h, { href: entryHref(entry), tone: "muted" }, [ icon(h, "LinkIcon", { size: 12 }), typography(h, { size: "2xs" }, ["Link to entry"]), ]), diff --git a/apps/web/src/page/settings/audit-log/view.ts b/apps/web/src/page/settings/audit-log/view.ts index 4dc7e7db23..cd75d4adb0 100644 --- a/apps/web/src/page/settings/audit-log/view.ts +++ b/apps/web/src/page/settings/audit-log/view.ts @@ -42,6 +42,7 @@ import { refreshButton } from "@maple/views/ui/refresh-button" import { segmentedSelect } from "@maple/views/ui/segmented-select" import * as SelectView from "@maple/views/ui/select-view" import { skeletonList } from "@maple/views/ui/skeleton" +import { tooltipTrigger } from "@maple/views/ui/tooltip-host" import { truncatedText } from "@maple/views/ui/truncated-text" import { relativeTime } from "../../kit/relative-time" import { filteredEmpty } from "@maple/views/ui/signal-empty-state" @@ -77,7 +78,7 @@ const ACTOR_FILTERS = [ const KIND_OPTIONS = [ { value: "changes", label: "Changes", icon: "PencilIcon" }, { value: "access", label: "Data access", icon: "EyeIcon" }, - { value: "all", label: "All", icon: "LayersIcon" }, + { value: "all", label: "All" }, ] as const satisfies ReadonlyArray> const OUTCOME_FILTERS = [ { value: "all", label: "All" }, @@ -117,11 +118,11 @@ const ACTOR_BADGES = { const COL = { time: "flex w-22 shrink-0 items-center gap-2", - actor: "min-w-0 flex-1 @md:w-38 @md:flex-none @3xl:w-42", + 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", // Shares the row's spare width with the action, so neither starves the other when it appears. resource: "hidden min-w-0 @3xl:block @3xl:basis-0 @3xl:flex-1", - source: "hidden w-20 shrink-0 @2xl:block", + source: "hidden w-24 shrink-0 @2xl:block", outcome: "hidden w-16 shrink-0 @md:block", } @@ -160,6 +161,10 @@ const rangeLabel = (range: AuditDateRange, timeZone: string): string => { // TOOLBAR +// On a phone the four filters share each row evenly instead of ending wherever their label does. +const FILTER_GROW = "grow basis-36 @md:grow-0 @md:basis-auto" +const SELECT_FIT = cn("w-auto max-w-none @md:max-w-64", FILTER_GROW) + const RANGE_OPTION_CLASS = "flex h-7 items-center px-3 text-left text-xs hover:bg-surface-sunken active:bg-surface-sunken" @@ -181,7 +186,8 @@ const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): view: Popover.view, toParentMessage: (message) => Message.GotDateRangeMessage({ message }), viewInputs: { - className: model.customRange === null ? "w-48 p-0" : "w-auto p-0", + // `w-max`, not `w-auto`: the positioner keeps the presets' width, and auto would stay inside it. + className: model.customRange === null ? "w-48 p-0" : "w-max max-w-(--available-width) p-0", toView: ({ trigger, withTrigger }) => h.div( [h.Class("contents")], @@ -191,11 +197,18 @@ const dateRange = (h: H, model: Model, range: AuditDateRange, timeZone: string): { variant: "outline", size: "sm", - className: "gap-1.5", + className: cn("justify-start gap-1.5 @md:justify-center", FILTER_GROW), slot: "popover-trigger", attributes: [h.AriaLabel(`Date range: ${label}`), ...trigger], }, - [icon(h, "ClockIcon"), h.span([], [label])], + [ + icon(h, "ClockIcon", { className: "text-muted-foreground" }), + typography( + h, + { weight: "normal", tone: range.kind === "all" ? "muted" : "foreground" }, + [label], + ), + ], ), ), ), @@ -268,7 +281,7 @@ const filterSelect = ( ], ariaLabel: options.ariaLabel, size: "sm", - className: "w-auto min-w-36 max-w-64", + className: SELECT_FIT, leading: filterGlyph(h, options.icon), renderValue: () => [ typography( @@ -341,9 +354,13 @@ const personFilter = ( entries, ariaLabel: "Person", size: "sm", - className: "w-auto min-w-40 max-w-64", + className: SELECT_FIT, leading: filterGlyph(h, ACTOR_ICONS.user), - renderValue: () => [typography(h, { truncate: true }, [label])], + renderValue: () => [ + typography(h, { truncate: true, tone: actorId === undefined ? "muted" : "foreground" }, [ + label, + ]), + ], toView: (parts) => h.div([h.Class("contents")], parts), }, }) @@ -357,15 +374,18 @@ const actorCell = (h: H, model: Model, entry: V2AuditLogEntry): Html => { return h.div( [h.Class(cn(COL.actor, hstackClass({ gap: "1.5" })))], [ + // One marker slot, so every name starts on the same line down the column. entry.actor_type === "user" ? avatarFallback( h, { className: "size-4 shrink-0" }, typography(h, { size: "4xs" }, [initialsOf(name ?? "")]), ) - : actorBadge(h, entry.actor_type), + : actorMark(h, entry.actor_type, name === null), ...(name === null - ? [] + ? entry.actor_type === "user" + ? [] + : [typography(h, { size: "xs", truncate: true }, [ACTOR_BADGES[entry.actor_type].label])] : [ truncatedText( h, @@ -410,6 +430,28 @@ export const deniedBadge = (h: H): Html => "Denied", ]) +/** + * A non-person actor in a row: its glyph alone, the size of a person's avatar. It names the actor + * type (and says it on hover) unless the type is already the text beside it. + */ +const actorMark = (h: H, actorType: AuditActorType, isNamedBeside: boolean): Html => + badge( + h, + { + variant: ACTOR_BADGES[actorType].variant, + size: "sm", + className: "size-4 min-w-4 shrink-0 px-0", + attributes: isNamedBeside + ? [h.AriaHidden(true)] + : [ + h.Role("img"), + h.AriaLabel(ACTOR_BADGES[actorType].label), + ...tooltipTrigger(h, ACTOR_BADGES[actorType].label), + ], + }, + [icon(h, ACTOR_ICONS[actorType])], + ) + /** What kind of actor made an entry: its glyph and its name. */ export const actorBadge = (h: H, actorType: AuditActorType): Html => badge(h, { variant: ACTOR_BADGES[actorType].variant, size: "sm", className: "shrink-0" }, [ @@ -529,12 +571,18 @@ const auditRow = (h: H, model: Model, entry: V2AuditLogEntry, inputs: ViewInputs ], ), h.div([h.Class(COL.resource)], [resourceCell(h, model, entry)]), + // Quiet: most rows share one source, and a box on each would outweigh the action. h.span( [h.Class(COL.source)], [ - badge(h, { variant: "outline", size: "sm" }, [ - icon(h, SOURCE_ICONS[entry.source]), - SOURCE_LABELS[entry.source], + hstack(h, { gap: "1.5", align: "center", as: "span" }, [ + icon(h, SOURCE_ICONS[entry.source], { + size: 12, + className: "text-foreground-subtle shrink-0", + }), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + SOURCE_LABELS[entry.source], + ]), ]), ], ), @@ -578,7 +626,7 @@ const listBody = (h: H, model: Model, inputs: ViewInputs, isFiltered: boolean): if (view.entries.length === 0 && isFiltered) return filteredEmpty(h, { noun: "entries", - description: "Nothing recorded for this range, person, actor type and outcome.", + description: "Nothing recorded matches these filters.", onClear: Message.ClickedClearFilters(), className: "py-8", }) @@ -626,7 +674,8 @@ const withDayHeadings = ( h.AriaLevel(3), ], [ - eyebrow(h, { variant: "mono", icon: "CalendarIcon" }, [ + // The gap the time column has, so the day sits above the times. + eyebrow(h, { variant: "mono", icon: "CalendarIcon", className: "gap-2" }, [ dayLabel(atMs(entry), clock), ]), ], @@ -699,7 +748,7 @@ const lookupField = (h: H, model: Model): ReadonlyArray => [ 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.", + access: "Telemetry and replay reads, MCP tool calls, SQL and API requests.", all: "Every entry, changes and data access together.", } as const @@ -724,18 +773,22 @@ export const view = Submodel.defineView((model, inpu [ 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: withGlyphs(h, KIND_OPTIONS), - }), - typography(h, { size: "xs", tone: "muted", className: "hidden @2xl:block" }, [ - KIND_DESCRIPTIONS[kind], - ]), - ]), + segmentedSelect(h, { + size: "sm", + ariaLabel: "Show", + value: kind, + onChange: (value) => Message.ClickedKind({ value }), + options: withGlyphs(h, KIND_OPTIONS), + }), + // Takes the width between the switch and Reload, and shortens before it moves either. + truncatedText(h, { + text: KIND_DESCRIPTIONS[kind], + tooltip: "overflow", + className: cn( + "hidden min-w-0 flex-1 @2xl:block", + typographyClass({ size: "xs", tone: "muted" }), + ), + }), // The labelled Reload every page header uses, beside the range it re-reads. refreshButton(h, { label: "Reload", diff --git a/packages/views/src/ui/attributes/attribute-row.ts b/packages/views/src/ui/attributes/attribute-row.ts index 353a4cb7f9..b81ffd29e1 100644 --- a/packages/views/src/ui/attributes/attribute-row.ts +++ b/packages/views/src/ui/attributes/attribute-row.ts @@ -4,7 +4,7 @@ import { prettyJson, tryParseJson } from "../../lib/display-text" import { Option, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { copyButton } from "../copy-button" -import { icon, type IconName } from "../icon" +import { icon } from "../icon" import { cappedCode } from "../logs/log-message" import { type AttributeSource, copyStatusOf, isOpen, Message, type Model } from "./attributes-panel" @@ -158,14 +158,7 @@ const commitShaValue = (h: H, id: string, value: string): Html => /** The per-row hover verb (`AttributeFilterAction`): the `tag` badge as a button. */ export const attributeFilterAction = ( h: HtmlBuilder, - options: Readonly<{ - label: string - text: string - onClick: M - stopPropagation?: boolean - /** A glyph before the verb. */ - icon?: IconName - }>, + options: Readonly<{ label: string; text: string; onClick: M; stopPropagation?: boolean }>, ): Html => h.button( [ @@ -183,7 +176,7 @@ export const attributeFilterAction = ( h.DataAttribute("slot", "badge"), options.stopPropagation === true ? h.OnClick(options.onClick, stop) : h.OnClick(options.onClick), ], - [...(options.icon === undefined ? [] : [icon(h, options.icon)]), options.text], + [options.text], ) const filterAction = (h: H, label: string, onClick: Message, text: string): Html => diff --git a/packages/views/src/ui/copy-button.ts b/packages/views/src/ui/copy-button.ts index dc9da42047..798f075b7b 100644 --- a/packages/views/src/ui/copy-button.ts +++ b/packages/views/src/ui/copy-button.ts @@ -215,7 +215,7 @@ export type CopyButtonOptions = Readonly<{ errorLabel?: string iconSize?: number variant?: "default" | "secondary" | "outline" | "ghost" - size?: "xs" | "sm" | "default" | "icon-xs" | "icon-sm" | "icon" + size?: "xs" | "sm" | "default" | "icon-2xs" | "icon-xs" | "icon-sm" | "icon" /** Keep a filled (`default`) button's own ink; the copy button's muted ink is for ghost ones. */ keepsFillInk?: boolean className?: string