/settings/audit-logAudit log
An append-only trail read either as a rail or as a table, filtered by actor and resource, with a diff viewer for what each entry changed.
The page awaits db.auditEvents.list for its first page; the search box, the actor and resource filters, the column sort and the pager rewrite one query the repository answers. The SegmentedControl swaps how the page is drawn — a Timeline rail or a table — never which entries it holds, so filters, sort and paging carry across the switch and both views show the rows the server sent. Clicking an entry's diff opens the InspectorPanel: the field-by-field before-and-after is the readable answer, and the JsonViewer under it is the record as the trail stored it. Nothing here writes to the trail, which is the point of one; the only action is an export, and it returns Result like every other. Composes AppShell, PageHeader, SegmentedControl, FilterBar, FilterMenu, SearchInput, ExportMenu, Timeline, DataTableColumnHeader, DataTableViewOptions, DataTablePagination, DataTableRows, InspectorPanel, DescriptionList, JsonViewer, StatusBadge, UserCell, DateCell and Callout.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AuditLogView } from "./components/audit-view"
import { currentUser, DEFAULT_QUERY, filterOptions, lastUpdated, listAuditEvents, shellNotifications } from "./data"
import { NAV, ROUTE } from "./nav"
/**
* The workspace's audit trail. The page is an async server component: it reads
* the first page through `db.auditEvents.list`, and the filters' choices, and
* hands both to the view, which asks the server for every page after that — the
* view never imports the module that reads the store. Nothing on this page
* writes to the trail — an audit log that could be edited would not be one.
*/
export default async function AuditLogPage() {
const initial = await listAuditEvents(DEFAULT_QUERY)
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="settings-audit-log-title"
title="Audit log"
description="Who changed what, when, and from where. Append-only."
meta={lastUpdated()}
/>
<AuditLogView titleId="settings-audit-log-title" initial={initial} options={filterOptions()} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/settings-audit-logNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AuditLogView } from "./components/audit-view"
import { currentUser, DEFAULT_QUERY, filterOptions, lastUpdated, listAuditEvents, shellNotifications } from "./data"
import { NAV, ROUTE } from "./nav"
/**
* The workspace's audit trail. The page is an async server component: it reads
* the first page through `db.auditEvents.list`, and the filters' choices, and
* hands both to the view, which asks the server for every page after that — the
* view never imports the module that reads the store. Nothing on this page
* writes to the trail — an audit log that could be edited would not be one.
*/
export default async function AuditLogPage() {
const initial = await listAuditEvents(DEFAULT_QUERY)
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="settings-audit-log-title"
title="Audit log"
description="Who changed what, when, and from where. Append-only."
meta={lastUpdated()}
/>
<AuditLogView titleId="settings-audit-log-title" initial={initial} options={filterOptions()} />
</AppShell>
)
}import { type NavConfig } from "@/lib/nav-config"
/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/settings/audit-log"
/**
* This product's navigation, as plain data. AppShell resolves the icon names
* and works out which item is current from the route, so nothing here is a
* component and nothing here says "I am the page you are on".
*/
export const NAV: NavConfig = {
brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
groups: [
{
label: "Workspace",
items: [
{ title: "Overview", href: "/saas", icon: "layout-dashboard" },
{ title: "Customers", href: "/ecommerce/customers", icon: "users" },
{ title: "Orders", href: "/ecommerce/orders", icon: "shopping-cart" },
{ title: "Invoices", href: "/finance/invoices", icon: "receipt" },
{ title: "Products", href: "/products", icon: "package" },
],
},
{
label: "Governance",
items: [{ title: "Audit log", href: "/settings/audit-log", icon: "shield" }],
},
],
// Pinned under the groups, the way the secondary links were.
footer: [
{ title: "Settings", href: "/settings", icon: "settings" },
{ title: "Support", href: "/support", icon: "life-buoy" },
],
}/**
* What this page reads. Every entry is a `db.auditEvents` row, paged by the
* repository rather than by the browser: `listAuditEvents` hands
* `db.auditEvents.list` the page, the sort, the search string and the actor and
* resource filters. The trail is immutable — nothing here writes to it — so the
* only action the page has is an export, and that returns `Result` like every
* other one. "Now" is `REFERENCE_DATE`.
*/
import { formatDate, getInitials } from "@/lib/format"
import { db, ownKey, REFERENCE_DATE, type AuditEvent, type Member, type Page } from "@/lib/sample-data"
import { resourceLabel, type AuditFilterOptions, type AuditQuery, type AuditRow } from "./vocabulary"
// The shapes, the first query and the labels live in `vocabulary.ts`, which
// the islands may import; this module reads `db`, and they never do.
export {
DEFAULT_QUERY,
PAGE_SIZE,
resourceLabel,
type AuditFilterOptions,
type AuditQuery,
type AuditRow,
} from "./vocabulary"
// The table's column ids on the left, the repository's fields on the right.
// Anything named in neither this map nor JOINED_SORTS below is not sortable.
const SORT_FIELDS: Record<string, keyof AuditEvent> = {
at: "at",
action: "action",
resource: "resource",
}
/**
* Columns the repository cannot sort, because the value it stores is not the
* value the table shows. The trail records `actor` as a `Member` id and the
* column reads that member's name, and ids do not order the way names do — a
* store with a real query language would say `ORDER BY member.name`. Sorting
* one of these reads the whole matched set here, on the server, resolves it and
* pages it by hand; the repository still does the searching and the filtering,
* and one page is still all that crosses the wire.
*/
const JOINED_SORTS: Record<string, (row: AuditRow) => string> = {
actorName: (row) => row.actorName,
}
/** The repository's own paging rules, applied to rows it has already handed over. */
function pageOf(rows: AuditRow[], page: number, pageSize: number): Page<AuditRow> {
const total = rows.length
const size = Math.max(1, pageSize)
const pageCount = Math.ceil(total / size)
const current = Math.min(Math.max(1, page), Math.max(1, pageCount))
const start = (current - 1) * size
return { rows: rows.slice(start, start + size), total, page: current, pageSize: size, pageCount }
}
// Read per call, never held at module scope: a member renamed since the server
// started is the actor's name on the next page, and an entry written since is
// in the counts.
const membersById = () => new Map(db.members.all().map((member) => [member.id, member]))
function toRow(event: AuditEvent, members: Map<string, Member>): AuditRow {
const actor = members.get(event.actor)
return {
id: event.id,
at: event.at,
actorId: event.actor,
actorName: actor?.name ?? "Unknown member",
actorEmail: actor?.email ?? "—",
actorAvatar: actor?.avatarUrl,
action: event.action,
resource: event.resource,
resourceLabel: resourceLabel(event.resource),
resourceId: event.resourceId,
ip: event.ip,
diff: event.diff,
}
}
/**
* One page of the trail, filtered and searched by the repository and sorted by
* it too, unless the column is one the repository cannot order (see
* JOINED_SORTS).
*/
export async function listAuditEvents(query: AuditQuery): Promise<Page<AuditRow>> {
const filters = { actor: query.actors, resource: query.resources }
// The maps' own keys only: a sort named "constructor" is not a column.
const joined = ownKey(JOINED_SORTS, query.sort.id) ? JOINED_SORTS[query.sort.id] : undefined
if (joined) {
const matched = await db.auditEvents.list({
pageSize: Number.MAX_SAFE_INTEGER,
search: query.search,
filters,
})
const direction = query.sort.desc ? -1 : 1
const members = membersById()
const rows = matched.rows
.map((event) => toRow(event, members))
.sort((a, b) => direction * joined(a).localeCompare(joined(b)))
return pageOf(rows, query.page, query.pageSize)
}
const field = ownKey(SORT_FIELDS, query.sort.id) ? SORT_FIELDS[query.sort.id] : undefined
const page = await db.auditEvents.list({
page: query.page,
pageSize: query.pageSize,
sort: field ? { id: field, desc: query.sort.desc } : undefined,
search: query.search,
filters,
})
const members = membersById()
return { ...page, rows: page.rows.map((event) => toRow(event, members)) }
}
/** Every entry the current filters leave, for an export that is not just this page. */
export async function allMatching(query: AuditQuery): Promise<AuditRow[]> {
const page = await listAuditEvents({ ...query, page: 1, pageSize: Number.MAX_SAFE_INTEGER })
return page.rows
}
function countBy(events: AuditEvent[], pick: (event: AuditEvent) => string): Map<string, number> {
const counts = new Map<string, number>()
for (const event of events) counts.set(pick(event), (counts.get(pick(event)) ?? 0) + 1)
return counts
}
/**
* The filters' choices, counted off the trail as it stands and handed to the
* toolbar as props. Only the members who actually appear in the trail are
* offered; the rest would filter to nothing.
*/
export function filterOptions(): AuditFilterOptions {
const events = db.auditEvents.all()
const members = membersById()
return {
actors: [...countBy(events, (event) => event.actor).entries()]
.map(([id, count]) => ({ value: id, label: members.get(id)?.name ?? id, count }))
.sort((a, b) => a.label.localeCompare(b.label)),
resources: [...countBy(events, (event) => event.resource).entries()]
.map(([resource, count]) => ({ value: resource, label: resourceLabel(resource), count }))
.sort((a, b) => a.label.localeCompare(b.label)),
}
}
/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
return `${db.auditEvents.all().length} entries, through ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { oneOf, tableQuery, type Page, type Result } from "@/lib/sample-data"
import { allMatching, DEFAULT_QUERY, listAuditEvents, type AuditQuery, type AuditRow } from "./data"
/** What an export can be written as: the two the menu offers. */
const FORMATS = ["csv", "json"] as const
/**
* What the page asks the server for. The trail is append-only by definition, so
* there is nothing here that edits an entry: the reader can page it, filter it
* and take a copy of it, and that copy is the one action that can fail.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/** One page of the trail for the query the reader has built up. */
export async function fetchAuditEvents(query: AuditQuery): Promise<Page<AuditRow>> {
return listAuditEvents(tableQuery(query, DEFAULT_QUERY))
}
/**
* Exports every entry the current filters leave, not just the page on show. A
* real app streams a file back; here the action reports what it would have
* written so the page can say so out loud.
*/
export async function exportAuditLog(
query: AuditQuery,
format: string
): Promise<Result<{ format: string; rows: number }>> {
if (!oneOf(format, FORMATS)) return { ok: false, error: { code: "invalid_input", message: "Export as CSV or JSON." } }
const rows = await allMatching(tableQuery(query, DEFAULT_QUERY))
if (rows.length === 0) {
return {
ok: false,
error: { code: "empty_export", message: "No entries match these filters." },
}
}
return { ok: true, data: { format, rows: rows.length } }
}/**
* What the audit log's islands share with its server side: the row and query
* shapes, the first question the page asks, the tones and the labels. Nothing
* here reads `db` — `data.ts` does, and the islands never import it — so this
* is the one module both halves may load, and the store stays on the server.
*/
import { type SortSpec } from "@/lib/sample-data"
/** One line of the trail, with the member who caused it resolved. */
export type AuditRow = {
id: string
at: Date
actorId: string
actorName: string
actorEmail: string
actorAvatar?: string
action: string
resource: string
resourceLabel: string
resourceId: string
ip: string
diff?: { field: string; from: string; to: string }[]
}
export type AuditQuery = {
page: number
pageSize: number
sort: SortSpec
search: string
actors: string[]
resources: string[]
}
/** One choice in the actor or resource filter, and how many entries it holds. */
export type AuditFilterOption = { value: string; label: string; count: number }
/** The filters' choices, read on the server and handed to the toolbar. */
export type AuditFilterOptions = { actors: AuditFilterOption[]; resources: AuditFilterOption[] }
export const PAGE_SIZE = 12
export const DEFAULT_QUERY: AuditQuery = {
page: 1,
pageSize: PAGE_SIZE,
sort: { id: "at", desc: true },
search: "",
actors: [],
resources: [],
}
/** "api_key" → "API key"; the trail stores resources as they are keyed. */
export function resourceLabel(resource: string): string {
if (resource === "api_key") return "API key"
const words = resource.replace(/_/g, " ")
return words.charAt(0).toUpperCase() + words.slice(1)
}
/**
* The tone each action reads in — a deletion is not the same news as a rename.
* The five names here are the ones both `StatusBadge` and `Timeline` know, so
* the table's pill and the timeline's dot are painted from one map.
*/
export const ACTION_TONE: Record<string, "neutral" | "success" | "warning" | "danger" | "info"> = {
created: "success",
enabled: "success",
invited: "info",
updated: "neutral",
exported: "info",
disabled: "warning",
revoked: "warning",
deleted: "danger",
}
/**
* When an entry was recorded, pinned to UTC and saying so. A trail is read
* across time zones and lined up against other systems' logs, so the one place
* the instant is written out names its zone instead of quietly borrowing the
* reader's — and it is written out once, which is why `diffDocument` below does
* not repeat it.
*/
const UTC_INSTANT = new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
timeStyle: "short",
timeZone: "UTC",
})
export function recordedAt(at: Date): string {
return `${UTC_INSTANT.format(at)} UTC`
}
/** Names one entry the way a control that opens it should: who, what, and on which record. */
export function changeLabel(row: AuditRow): string {
return `Show what ${row.actorName} changed on ${row.resourceLabel.toLowerCase()} ${row.resourceId}`
}
/**
* The diff as a record change: what the fields read before, and what they read
* after. The trail stores it as a list of field moves, which is the right shape
* to write and the wrong one to read. The instant is not repeated here — the
* panel's own header carries it, in UTC.
*/
export function diffDocument(row: AuditRow) {
return {
resource: row.resourceLabel,
id: row.resourceId,
changedBy: row.actorEmail,
before: Object.fromEntries((row.diff ?? []).map((change) => [change.field, change.from])),
after: Object.fromEntries((row.diff ?? []).map((change) => [change.field, change.to])),
}
}"use client"
import { FileDiffIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { DateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { ACTION_TONE, changeLabel, type AuditRow } from "../vocabulary"
/**
* The trail as a table. There is no selection column and no row menu: an audit
* entry cannot be edited, and the one thing you can do to it — read what it
* changed — is the button at the end of the row.
*/
export function auditColumns(onShowDiff: (row: AuditRow) => void): DataTableColumnDef<AuditRow>[] {
return [
{
accessorKey: "at",
header: ({ column }) => <DataTableColumnHeader column={column} title="When" />,
cell: ({ row }) => <DateCell date={row.original.at} className="text-muted-foreground" />,
meta: { label: "When" },
},
{
accessorKey: "actorName",
header: ({ column }) => <DataTableColumnHeader column={column} title="Actor" />,
cell: ({ row }) => (
<UserCell size="sm" name={row.original.actorName} description={row.original.actorEmail} src={row.original.actorAvatar} />
),
meta: { label: "Actor" },
},
{
accessorKey: "action",
header: ({ column }) => <DataTableColumnHeader column={column} title="Action" />,
cell: ({ row }) => <StatusBadge status={row.original.action} map={ACTION_TONE} />,
meta: { label: "Action" },
},
{
accessorKey: "resource",
header: ({ column }) => <DataTableColumnHeader column={column} title="Resource" />,
cell: ({ row }) => <span className="whitespace-nowrap">{row.original.resourceLabel}</span>,
meta: { label: "Resource" },
},
{
accessorKey: "resourceId",
header: ({ column }) => <DataTableColumnHeader column={column} title="Record" />,
enableSorting: false,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.resourceId}</span>,
meta: { label: "Record" },
},
{
accessorKey: "ip",
header: ({ column }) => <DataTableColumnHeader column={column} title="IP" />,
enableSorting: false,
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">{row.original.ip}</span>
),
meta: { align: "right", label: "IP" },
},
{
id: "diff",
size: 96,
enableSorting: false,
enableHiding: false,
header: () => <span className="sr-only">Changes</span>,
cell: ({ row }) =>
row.original.diff?.length ? (
<Button
variant="ghost"
size="sm"
onClick={() => onShowDiff(row.original)}
aria-label={changeLabel(row.original)}
>
<FileDiffIcon data-icon="inline-start" />
Diff
</Button>
) : (
<span className="text-xs text-muted-foreground">—</span>
),
meta: { align: "right", label: "Changes" },
},
]
}"use client"
import { FileDiffIcon } from "lucide-react"
import { formatDate } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Timeline, TimelineItem } from "@/components/ui/timeline"
import { ACTION_TONE, changeLabel, type AuditRow } from "../vocabulary"
export type AuditTimelineProps = {
rows: AuditRow[]
onShowDiff: (row: AuditRow) => void
/** Dims the rail while the next page is on its way. */
busy?: boolean
}
/**
* The same page of the trail as a rail. It shows exactly the rows the table
* would — the view toggle changes how the page is drawn, never which entries it
* holds — so paging and filtering carry across the switch untouched.
*/
export function AuditTimeline({ rows, onShowDiff, busy = false }: AuditTimelineProps) {
if (rows.length === 0) {
return (
<p role="status" className="panel py-10 text-center text-sm text-muted-foreground">
No entries match these filters.
</p>
)
}
return (
// The rail sits in the same bordered panel the table view uses, so the
// toggle changes what is drawn without moving the page around it.
<div data-slot="audit-rail" className="panel p-4">
<Timeline
aria-label="Audit trail"
aria-busy={busy || undefined}
className={busy ? "opacity-60 transition-opacity" : "transition-opacity"}
>
{rows.map((row) => (
<TimelineItem
key={row.id}
tone={ACTION_TONE[row.action] ?? "neutral"}
time={formatDate(row.at, "medium", { timeZone: "UTC" })}
title={
<span className="font-normal">
<span className="font-medium">{row.actorName}</span>{" "}
<span className="text-muted-foreground">{row.action}</span>{" "}
<span>{row.resourceLabel.toLowerCase()}</span>{" "}
<span className="font-mono text-xs">{row.resourceId}</span>
</span>
}
description={
<span className="font-mono text-xs text-muted-foreground">from {row.ip}</span>
}
>
{row.diff?.length ? (
<Button
variant="ghost"
size="sm"
className="-ml-2 mt-1 self-start"
onClick={() => onShowDiff(row)}
aria-label={changeLabel(row)}
>
<FileDiffIcon data-icon="inline-start" />
{row.diff.length} field{row.diff.length === 1 ? "" : "s"} changed
</Button>
) : null}
</TimelineItem>
))}
</Timeline>
</div>
)
}"use client"
import { ListIcon, TableIcon } from "lucide-react"
import type { RowData } from "@tanstack/react-table"
import { DataTableViewOptions, type DataTableInstance } from "@/components/ui/data-table"
import { ExportMenu, type ExportFormat } from "@/components/ui/export-menu"
import { FilterBar, FilterMenu } from "@/components/ui/filter-bar"
import { SearchInput } from "@/components/ui/search-input"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { type AuditFilterOptions } from "../vocabulary"
export type AuditView = "timeline" | "table"
export type AuditToolbarProps<TData extends RowData> = {
/** Only the table view has columns to hide, so the View menu comes and goes with it. */
table: DataTableInstance<TData> | null
view: AuditView
onViewChange: (view: AuditView) => void
search: string
onSearchChange: (value: string) => void
actors: string[]
onActorsChange: (value: string[]) => void
resources: string[]
onResourcesChange: (value: string[]) => void
onClearFilters: () => void
activeCount: number
onExport: (format: ExportFormat) => Promise<void>
/** "1–12 of 500 entries", built from the page the server sent back. */
summary: string
/** The actor and resource choices, counted on the server. */
options: AuditFilterOptions
}
/**
* Everything above the trail: the two views, the search box, the actor and
* resource filters, what the page is showing, and a copy of it.
*/
export function AuditToolbar<TData extends RowData>({
table,
view,
onViewChange,
search,
onSearchChange,
actors,
onActorsChange,
resources,
onResourcesChange,
onClearFilters,
activeCount,
onExport,
summary,
options,
}: AuditToolbarProps<TData>) {
return (
<FilterBar activeCount={activeCount} onClearAll={activeCount > 0 ? onClearFilters : undefined}>
<SegmentedControl
size="sm"
aria-label="How to read the trail"
value={view}
onValueChange={(next) => onViewChange(next as AuditView)}
options={[
{ value: "timeline", label: "Timeline", icon: <ListIcon /> },
{ value: "table", label: "Table", icon: <TableIcon /> },
]}
/>
<SearchInput
size="sm"
// Controlled, so "Clear all" empties the box as well as the query —
// with a debounce the field still keeps its own draft while typing.
value={search}
onValueChange={onSearchChange}
debounce={200}
placeholder="Search action or record"
aria-label="Search the trail"
className="w-full max-w-56"
/>
<FilterMenu
label="Actor"
size="sm"
options={options.actors}
value={actors}
onValueChange={onActorsChange}
/>
<FilterMenu
label="Resource"
size="sm"
options={options.resources}
value={resources}
onValueChange={onResourcesChange}
/>
<p
data-slot="audit-summary"
aria-live="polite"
className="text-sm tabular-nums text-muted-foreground"
>
{summary}
</p>
<div className="ml-auto flex items-center gap-2">
{table ? <DataTableViewOptions table={table} className="ms-0" /> : null}
<ExportMenu size="sm" formats={["csv", "json"]} onExport={onExport} />
</div>
</FilterBar>
)
}"use client"
import * as React from "react"
import { useServerTable } from "@/hooks/use-server-table"
import { formatNumber } from "@/lib/format"
import { type Page } from "@/lib/sample-data"
import { Callout } from "@/components/ui/callout"
import { DataTablePagination, DataTableRows } from "@/components/ui/data-table"
import type { ExportFormat } from "@/components/ui/export-menu"
import { exportAuditLog, fetchAuditEvents } from "../actions"
import { DEFAULT_QUERY, type AuditFilterOptions, type AuditQuery, type AuditRow } from "../vocabulary"
import { auditColumns } from "./audit-columns"
import { AuditTimeline } from "./audit-timeline"
import { AuditToolbar, type AuditView as ViewMode } from "./audit-toolbar"
import { DiffViewer } from "./diff-viewer"
type Notice = { tone: "success" | "danger"; message: string }
/**
* The audit trail, read either way. The toggle changes how the page is drawn,
* never which entries it holds: both views render the same rows the repository
* sent, and the filters, the sort and the pager carry across the switch. The
* table instance is built either way, because the View menu and the pager read
* it even while the timeline is on show. useServerTable holds the query and
* asks the repository for each page, the newest question's answer winning.
*/
export function AuditLogView({
titleId,
initial,
options,
}: {
titleId: string
initial: Page<AuditRow>
/** The filters' choices, read on the server with the first page. */
options: AuditFilterOptions
}) {
const [view, setView] = React.useState<ViewMode>("timeline")
const [diff, setDiff] = React.useState<AuditRow | null>(null)
const [notice, setNotice] = React.useState<Notice | null>(null)
const columns = React.useMemo(() => auditColumns(setDiff), [])
const { table, query, page, pending, apply, summary } = useServerTable({
initialQuery: DEFAULT_QUERY,
initialPage: initial,
fetchPage: fetchAuditEvents,
columns,
getRowId: (row) => row.id,
defaultSort: DEFAULT_QUERY.sort,
noun: ["entry", "entries"],
})
/** A new filter asks a new question, so the last export's notice goes with the old one. */
function filter(patch: Partial<AuditQuery>) {
setNotice(null)
apply(patch)
}
async function runExport(format: ExportFormat) {
const result = await exportAuditLog(query, format)
if (!result.ok) return setNotice({ tone: "danger", message: result.error.message })
setNotice({
tone: "success",
message: `Queued ${formatNumber(result.data.rows)} entries as ${format.toUpperCase()}.`,
})
}
return (
<div className="flex flex-col gap-4">
<AuditToolbar
table={view === "table" ? table : null}
view={view}
onViewChange={setView}
search={query.search}
onSearchChange={(search) => filter({ search })}
actors={query.actors}
onActorsChange={(actors) => filter({ actors })}
resources={query.resources}
onResourcesChange={(resources) => filter({ resources })}
onClearFilters={() => filter({ search: "", actors: [], resources: [] })}
activeCount={query.actors.length + query.resources.length + (query.search ? 1 : 0)}
onExport={runExport}
summary={summary}
options={options}
/>
{notice ? (
<Callout
role={notice.tone === "danger" ? "alert" : "status"}
variant={notice.tone}
title={notice.tone === "danger" ? "That did not run" : "Export queued"}
>
{notice.message}
</Callout>
) : null}
{/* The pager is the same instance under either view; the table seats it
in its own footer strip, the timeline draws it beneath. */}
{view === "timeline" ? (
<>
<AuditTimeline rows={page.rows} busy={pending} onShowDiff={setDiff} />
<DataTablePagination table={table} showSelectedCount={false} pageSizeOptions={[12, 24, 48]} />
</>
) : (
<DataTableRows
table={table}
aria-labelledby={titleId}
busy={pending}
emptyMessage="No entries match these filters."
footer={
<DataTablePagination table={table} showSelectedCount={false} pageSizeOptions={[12, 24, 48]} />
}
/>
)}
<DiffViewer row={diff} onClose={() => setDiff(null)} />
</div>
)
}"use client"
import * as React from "react"
import { DescriptionList } from "@/components/ui/description-list"
import { InspectorPanel } from "@/components/ui/inspector-panel"
import { JsonViewer } from "@/components/ui/json-viewer"
import { SectionHeader } from "@/components/ui/section-header"
import { diffDocument, recordedAt, type AuditRow } from "../vocabulary"
/**
* What one entry changed. The field-by-field list is the readable answer; the
* JSON below it is the record as it stood before and after, which is the thing
* an auditor copies into a ticket.
*
* The before/after chips are not monospaced: a diff value here is as often a
* workspace name as it is a key, and font-mono is for ids and codes.
*/
export function DiffViewer({ row, onClose }: { row: AuditRow | null; onClose: () => void }) {
// The panel outlives the selection by one animation, so the last entry stays
// rendered while the sheet slides out instead of blanking mid-flight.
const [shown, setShown] = React.useState(row)
if (row && row.id !== shown?.id) setShown(row)
if (!shown) return null
const changes = shown.diff ?? []
return (
<InspectorPanel
open={row !== null}
mode="overlay"
width="lg"
onOpenChange={(next) => {
if (!next) onClose()
}}
title={
<span>
{shown.actorName} {shown.action} {shown.resourceLabel.toLowerCase()}
</span>
}
description={`${recordedAt(shown.at)} · ${shown.ip}`}
>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<SectionHeader as="h2" size="sm" title="Fields" />
<DescriptionList
items={changes.map((change) => ({
term: change.field,
description: (
<span className="flex flex-wrap items-center gap-2">
{/* The arrow is the only thing saying which value is which,
and an arrow reads as nothing at all out loud. */}
<span className="rounded bg-danger-muted px-1.5 py-0.5 text-xs text-danger line-through">
<span className="sr-only">from </span>
<span>{change.from}</span>
</span>
<span aria-hidden="true" className="text-muted-foreground">
→
</span>
<span className="rounded bg-success-muted px-1.5 py-0.5 text-xs text-success">
<span className="sr-only">to </span>
<span>{change.to}</span>
</span>
</span>
),
}))}
/>
</div>
<div className="flex flex-col gap-3">
<SectionHeader
as="h2"
size="sm"
title="Record"
description="The entry exactly as the trail stored it."
/>
<JsonViewer data={diffDocument(shown)} rootName="entry" maxHeight={320} />
</div>
</div>
</InspectorPanel>
)
}