diff --git a/app/components/form/fields/DateTimeRangePicker.tsx b/app/components/form/fields/DateTimeRangePicker.tsx
index 49f1081bb..8312e19cb 100644
--- a/app/components/form/fields/DateTimeRangePicker.tsx
+++ b/app/components/form/fields/DateTimeRangePicker.tsx
@@ -42,9 +42,7 @@ const tz = getLocalTimeZone()
/**
* Exposes `startTime` and `endTime` plus the whole set of picker UI controls as
- * a JSX element to render. When we're using a relative preset like last N
- * hours, automatically slide the window forward live by updating the range to
- * have `endTime` of _now_ every `SLIDE_INTERVAL` ms.
+ * a JSX element to render.
*/
export function useDateTimeRangePicker({
initialPreset,
@@ -84,15 +82,17 @@ export function useDateTimeRangePicker({
items,
}
- // Without these useMemos, we get re-renders every 400ms because when the
- // debounce timeout expires, it updates the value, which triggers a render for
- // itself because the time gets remade by toDate() (i.e., even though it is
- // the same time, it is a new object)
- const rangeStart = useMemo(() => range.start.toDate(tz), [range.start])
- const [startTime] = useDebounce(rangeStart, 400)
-
- const rangeEnd = useMemo(() => range.end.toDate(tz), [range.end])
- const [endTime] = useDebounce(rangeEnd, 400)
+ // Debounce only while a custom range is being edited: the date fields fire
+ // onChange on every keystroke. Picking a preset is a single deliberate action
+ // and applies immediately. The range is debounced as one value so start and
+ // end can't land in separate renders and fire a request for a mixed range.
+ const [debouncedRange] = useDebounce(range, 400)
+ const effectiveRange = preset === 'custom' ? debouncedRange : range
+
+ // toDate() makes a new Date each call, so memoize on the stable DateValue to
+ // keep the query key from changing on every render
+ const startTime = useMemo(() => effectiveRange.start.toDate(tz), [effectiveRange.start])
+ const endTime = useMemo(() => effectiveRange.end.toDate(tz), [effectiveRange.end])
return {
startTime,
diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx
index fca0d33b8..17f296df6 100644
--- a/app/layouts/SystemLayout.tsx
+++ b/app/layouts/SystemLayout.tsx
@@ -12,6 +12,7 @@ import {
Access16Icon,
Cloud16Icon,
IpGlobal16Icon,
+ Logs16Icon,
Metrics16Icon,
Servers16Icon,
SoftwareUpdate16Icon,
@@ -57,6 +58,7 @@ export default function SystemLayout() {
{ value: 'Subnet Pools', path: pb.subnetPools() },
{ value: 'System Update', path: pb.systemUpdate() },
{ value: 'Fleet Access', path: pb.fleetAccess() },
+ { value: 'Audit Log', path: pb.auditLog() },
]
// filter out the entry for the path we're currently on
.filter((i) => i.path !== pathname)
@@ -107,6 +109,9 @@ export default function SystemLayout() {
Fleet Access
+
+ Audit Log
+
diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx
new file mode 100644
index 000000000..655c7969b
--- /dev/null
+++ b/app/pages/system/AuditLog.tsx
@@ -0,0 +1,891 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { getLocalTimeZone, now } from '@internationalized/date'
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
+import { useWindowVirtualizer } from '@tanstack/react-virtual'
+import cn from 'classnames'
+import { differenceInMilliseconds } from 'date-fns'
+import {
+ memo,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { Link } from 'react-router'
+import { match, P } from 'ts-pattern'
+import { type JsonValue } from 'type-fest'
+
+import {
+ api,
+ qErrorsAllowed,
+ type AuditLogEntry,
+ type AuditLogListQueryParams,
+} from '@oxide/api'
+import {
+ Close12Icon,
+ Error12Icon,
+ Logs16Icon,
+ Logs24Icon,
+ NextArrow12Icon,
+ PrevArrow12Icon,
+} from '@oxide/design-system/icons/react'
+import { Badge } from '@oxide/design-system/ui'
+
+import { snakeify } from '~/api/__generated__/util'
+import { DocsPopover } from '~/components/DocsPopover'
+import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker'
+import { EmptyCell } from '~/table/cells/EmptyCell'
+import { Button } from '~/ui/lib/Button'
+import { CopyToClipboard } from '~/ui/lib/CopyToClipboard'
+import { Divider } from '~/ui/lib/Divider'
+import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
+import { PropertiesTable } from '~/ui/lib/PropertiesTable'
+import { Truncate } from '~/ui/lib/Truncate'
+import { classed } from '~/util/classed'
+import { toLocaleDateString, toSyslogDateString, toSyslogTimeString } from '~/util/date'
+import { docLinks } from '~/util/links'
+import { pb } from '~/util/path-builder'
+import { Rando } from '~/util/rando'
+
+export const handle = { crumb: 'Audit Log' }
+
+const Indent = ({ depth }: { depth: number }) => (
+
+)
+
+const greenText = 'text-(--color-green-1000) light:text-(--color-green-600)'
+const yellowText = 'text-(--color-yellow-1000) light:text-(--color-yellow-600)'
+
+const Primitive = ({ value }: { value: JsonValue | Date }) => {
+ if (value === null) return null
+ if (typeof value === 'string') return {`"${value}"`}
+ if (value instanceof Date)
+ return {`"${value.toISOString()}"`}
+ if (typeof value === 'boolean' || typeof value === 'number') {
+ return {String(value)}
+ }
+ // objects/arrays are handled by HighlightJSON, never reach here
+ return null
+}
+
+// memo is important to avoid re-renders if the value hasn't changed. value
+// passed in must be referentially stable, which should generally be the case
+// with API responses
+const HighlightJSON = memo(({ json, depth = 0 }: { json: JsonValue; depth?: number }) => {
+ if (json === undefined) return null
+
+ if (
+ json === null ||
+ typeof json === 'boolean' ||
+ typeof json === 'number' ||
+ typeof json === 'string' ||
+ // special case. the types don't currently reflect that this is possible.
+ // dates have type object so you can't use typeof
+ json instanceof Date
+ ) {
+ return
+ }
+
+ if (Array.isArray(json)) {
+ if (json.length === 0) return []
+
+ return (
+ <>
+ [
+ {'\n'}
+ {json.map((item, index) => (
+
+
+
+ {index < json.length - 1 && ,}
+ {'\n'}
+
+ ))}
+
+ ]
+ >
+ )
+ }
+
+ const entries = Object.entries(json)
+ if (entries.length === 0) return {'{}'}
+
+ return (
+ <>
+ {'{'}
+ {'\n'}
+ {entries.map(([key, val], index) => (
+
+
+ {key}
+ :
+
+ {index < entries.length - 1 && ,}
+ {'\n'}
+
+ ))}
+
+ {'}'}
+ >
+ )
+})
+
+const ErrorState = ({ error, onDismiss }: { error: string; onDismiss: () => void }) => {
+ return (
+
+ )
+})
+
+export default function AuditLogPage() {
+ const [expandedItem, setExpandedItem] = useState(null)
+ const [errorMessage, setErrorMessage] = useState(null)
+
+ const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({
+ initialPreset: 'lastHour',
+ maxValue: now(getLocalTimeZone()),
+ })
+
+ const queryParams: AuditLogListQueryParams = {
+ startTime,
+ endTime,
+ sortBy: 'time_and_id_descending',
+ limit: PAGE_LIMIT,
+ }
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isFetching } =
+ useInfiniteQuery({
+ queryKey: ['auditLogList', { query: queryParams }],
+ queryFn: ({ pageParam }) =>
+ api
+ .auditLogList({ query: { ...queryParams, pageToken: pageParam } })
+ .then((result) => {
+ if (result.type === 'success') return result.data
+ setErrorMessage(
+ result.type === 'error'
+ ? result.data.message
+ : 'An error occurred while loading the audit log'
+ )
+ throw result
+ }),
+ initialPageParam: undefined as string | undefined,
+ // Dropshot hands back a next-page token whenever a page has items, even
+ // the last one, so the token alone can't tell us we're done. A short page
+ // can: anything under the limit means there's nothing after it.
+ // https://github.com/oxidecomputer/dropshot/blob/4ff9cb3/dropshot/src/pagination.rs#L168-L176
+ getNextPageParam: (lastPage) =>
+ lastPage.items.length < PAGE_LIMIT ? undefined : lastPage.nextPage || undefined,
+ // no placeholderData on purpose: a time range change should show the
+ // skeleton rather than the previous range's rows while the new one loads
+ })
+
+ // a time range change is a new list: clear the error, close the detail pane
+ // (its index would point at a different entry), and start from the top
+ useEffect(() => {
+ setErrorMessage(null)
+ setExpandedItem(null)
+ window.scrollTo({ top: 0 })
+ }, [startTime, endTime])
+
+ const allItems = useMemo(() => {
+ return data?.pages.flatMap((page) => page.items) || []
+ }, [data])
+
+ // Not TanStack's isLoading, which is only true before a range's first result.
+ // A range seen before has a cache entry and refetches in the background, but
+ // late-arriving entries mean the cached rows may not be the final set for
+ // that window, so show the skeleton until the refetch lands rather than rows
+ // that might shift. Next-page fetches keep the list and spin the button.
+ const loading = isFetching && !isFetchingNextPage
+
+ const parentRef = useRef(null)
+ // virtual rows are positioned by their offset from the top of the document, so
+ // the virtualizer needs to know how far down the page the list starts
+ const [scrollMargin, setScrollMargin] = useState(0)
+
+ const rowVirtualizer = useWindowVirtualizer({
+ count: allItems.length,
+ estimateSize: () => 36,
+ overscan: 40,
+ scrollMargin,
+ })
+
+ // scroll just enough to bring the row at `index` into the band between the
+ // sticky header bottom and the viewport midpoint. only used for keyboard /
+ // prev-next navigation — clicks intentionally leave scroll alone so the
+ // clicked row stays under the cursor.
+ const scrollToRow = useCallback(
+ (index: number) => {
+ // top-bar (54px) + sticky table header (~40px)
+ const stickyBottom = 54 + 40
+ const itemTop = scrollMargin + index * 36
+ const viewportTop = itemTop - window.scrollY
+ // floor: scroll at least enough to fully stick the header so the
+ // expanded-item panel reaches its full height
+ const minScroll = scrollMargin - stickyBottom - 10
+ let target = window.scrollY
+ if (viewportTop < stickyBottom) {
+ target = itemTop - stickyBottom - 1
+ } else if (viewportTop > window.innerHeight / 2) {
+ target = itemTop - window.innerHeight / 2
+ }
+ target = Math.max(target, minScroll)
+ if (target !== window.scrollY) window.scrollTo({ top: target })
+ },
+ [scrollMargin]
+ )
+
+ const navigateToIndex = useCallback(
+ (newIndex: number) => {
+ if (newIndex < 0 || newIndex >= allItems.length) return
+ setExpandedItem(newIndex)
+ scrollToRow(newIndex)
+ },
+ [allItems.length, scrollToRow]
+ )
+
+ const focusRow = useCallback((index: number) => {
+ parentRef.current
+ ?.querySelector(`[data-row-index="${index}"]`)
+ ?.focus({ preventScroll: true })
+ }, [])
+
+ // arrow keys (and j/k when the pane is open) move selection (and focus)
+ // between rows; escape closes the pane. with the pane closed, arrows still
+ // move focus when a row is focused, without opening the pane. adjacent rows
+ // are within the virtualizer's overscan, so they're already in the DOM when
+ // we look them up.
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ const target = e.target as HTMLElement | null
+ // don't hijack typing in inputs (e.g. the date pickers above the list)
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return
+
+ // current row: the expanded one, or (modal closed) the focused one.
+ // closest() so this also works when focus is on a copy button inside a row
+ const currentIdx =
+ expandedItem ??
+ parseInt(
+ target?.closest('[data-row-index]')?.getAttribute('data-row-index') ?? '',
+ 10
+ )
+ if (Number.isNaN(currentIdx)) return
+
+ if (e.key === 'Escape' && expandedItem !== null) {
+ e.preventDefault()
+ setExpandedItem(null)
+ // restore focus to the row in case focus was inside the modal
+ focusRow(currentIdx)
+ return
+ }
+
+ // j/k only with the pane open: with it closed they'd move focus with no
+ // visible selection, and bare letters on a page feel like they might be typing
+ const paneOpen = expandedItem !== null
+ const delta = match(e.key)
+ .with('ArrowDown', () => 1)
+ .with('ArrowUp', () => -1)
+ .with('j', () => (paneOpen ? 1 : 0))
+ .with('k', () => (paneOpen ? -1 : 0))
+ .otherwise(() => 0)
+ if (delta === 0) return
+ e.preventDefault()
+ const next = currentIdx + delta
+ if (next < 0 || next >= allItems.length) return
+ // with the modal open, selection follows focus; closed, only focus moves
+ if (expandedItem !== null) setExpandedItem(next)
+ scrollToRow(next)
+ focusRow(next)
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [expandedItem, allItems.length, focusRow, scrollToRow])
+
+ // the list has everything it's going to get for this range
+ const settled = !hasNextPage && !isFetching && !isPending && errorMessage === null
+
+ const logTable = (
+ <>
+
+ {allItems.length === 0 ? 'No logs' : 'No more logs'} in selected time range
+
+ ) : (
+
+ )}
+
+ >
+ )
+
+ // the row can be gone if a refetch shrank the list
+ const selectedItem = expandedItem !== null ? allItems[expandedItem] : undefined
+
+ // measure the list's distance from the top of the document so the window
+ // virtualizer can position items correctly. re-measure when the error banner
+ // or loading state toggles, since those shift the list's position
+ useLayoutEffect(() => {
+ if (parentRef.current) {
+ const rect = parentRef.current.getBoundingClientRect()
+ setScrollMargin(rect.top + window.scrollY)
+ }
+ }, [loading])
+
+ return (
+ <>
+
+
+ }>Audit Log
+ }
+ summary="The audit log records every API request that can modify the system. Read-only requests are not logged."
+ links={[docLinks.auditLog]}
+ />
+
+
+
(
const limit = params.limit || 100
const pageToken = params.pageToken
- let startIndex = pageToken ? items.findIndex((i) => i.id === pageToken) : 0
- startIndex = startIndex < 0 ? 0 : startIndex
-
- if (startIndex > items.length) {
- return {
- items: [],
- next_page: null,
- }
- }
-
- if (limit + startIndex >= items.length) {
- return {
- items: items.slice(startIndex),
- next_page: null,
- }
- }
+ // no token, or an unknown one, starts from the beginning
+ const tokenIndex = pageToken ? items.findIndex((i) => i.id === pageToken) : -1
+ const startIndex = tokenIndex + 1
+ const page = items.slice(startIndex, startIndex + limit)
return {
- items: items.slice(startIndex, startIndex + limit),
- next_page: items[startIndex + limit].id,
+ items: page,
+ next_page: page.at(-1)?.id ?? null,
}
}
diff --git a/mock-api/snapshot.ts b/mock-api/snapshot.ts
index 3740b29a8..e9758173d 100644
--- a/mock-api/snapshot.ts
+++ b/mock-api/snapshot.ts
@@ -10,11 +10,11 @@ import { v4 as uuid } from 'uuid'
import type { Snapshot } from '@oxide/api'
+import { Rando } from '~/util/rando'
import { GiB } from '~/util/units'
import { disks } from './disk'
import type { Json } from './json-type'
-import { Rando } from './msw/rando'
import { project } from './project'
// Use seeded random for consistent states across runs
diff --git a/test/e2e/audit-log.e2e.ts b/test/e2e/audit-log.e2e.ts
new file mode 100644
index 000000000..a30aeabf2
--- /dev/null
+++ b/test/e2e/audit-log.e2e.ts
@@ -0,0 +1,106 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { expect, test, type Page } from '@playwright/test'
+
+// rows are role=button so they can be keyboard-toggled; the row's accessible
+// name includes the operation, which is enough to pick a row
+const row = (page: Page, operation: string) =>
+ page.getByRole('button', { name: new RegExp(operation, 'i') }).first()
+
+const pane = (page: Page) => page.getByRole('complementary', { name: 'Audit log entry' })
+
+test('lists entries and opens detail pane', async ({ page }) => {
+ await page.goto('/system/audit-log')
+ await expect(page.getByRole('heading', { name: 'Audit Log' })).toBeVisible()
+ await expect(row(page, 'instance create')).toBeVisible()
+
+ await row(page, 'instance create').click()
+ const detail = pane(page)
+ await expect(detail).toBeVisible()
+ await expect(detail.getByText('Instance Create')).toBeVisible()
+ // status badge in the header; the same number also appears in the raw JSON
+ await expect(
+ detail.getByRole('heading', { level: 3 }).locator('..').getByText('201')
+ ).toBeVisible()
+
+ // actor and silo IDs resolve to names, and the silo links to its page
+ await expect(detail.getByText('Hannah Arendt')).toBeVisible()
+ await expect(detail.getByRole('link', { name: 'maze-war' })).toBeVisible()
+
+ await page.getByRole('button', { name: 'Close' }).click()
+ await expect(detail).toBeHidden()
+})
+
+test('shows Not found for deleted actor and silo', async ({ page }) => {
+ await page.goto('/system/audit-log')
+ // this mock entry points at a user ID and silo ID that don't exist
+ await row(page, 'instance delete').click()
+ const detail = pane(page)
+ await expect(detail.getByText('Instance Delete')).toBeVisible()
+ await expect(detail.getByText('Not found')).toHaveCount(2)
+})
+
+test('keyboard navigation between entries', async ({ page }) => {
+ await page.goto('/system/audit-log')
+ await row(page, 'instance start').click()
+ const detail = pane(page)
+ await expect(detail.getByText('Instance Start')).toBeVisible()
+
+ // arrows and j/k both move the selection while the pane is open
+ await page.keyboard.press('ArrowDown')
+ await expect(detail.getByText('Instance Delete')).toBeVisible()
+ await page.keyboard.press('j')
+ await expect(detail.getByText('User Login')).toBeVisible()
+ await page.keyboard.press('k')
+ await expect(detail.getByText('Instance Delete')).toBeVisible()
+
+ await page.keyboard.press('Escape')
+ await expect(detail).toBeHidden()
+
+ // with the pane closed, j does nothing
+ await page.keyboard.press('j')
+ await expect(detail).toBeHidden()
+})
+
+test('stops offering Load More after a short page', async ({ page }) => {
+ await page.goto('/system/audit-log')
+ await expect(row(page, 'instance create')).toBeVisible()
+
+ // the default range holds more than one page, so the first page is full
+ const loadMore = page.getByRole('button', { name: 'Load More' })
+ await expect(loadMore).toBeVisible()
+ await loadMore.click()
+
+ // the second page is short. The API still returns a next-page token, but a
+ // short page means there's nothing left, so the button should give way
+ await expect(page.getByText('No more logs in selected time range')).toBeVisible()
+ await expect(loadMore).toBeHidden()
+})
+
+test('shows an empty message when the time range has no entries', async ({ page }) => {
+ await page.goto('/system/audit-log')
+ await expect(row(page, 'instance create')).toBeVisible()
+
+ // Mock entries all fall within the last several hours, so a range ending
+ // days ago is empty. In the range calendar the first Enter anchors a
+ // selection and the second completes it, so finish a throwaway [today, today]
+ // range first. Then only ever move left: anchor at today-6 and complete
+ // further back. Moving right can cross into the current month, where focus
+ // jumps to today (the max date) and the range swallows every entry.
+ await page.getByLabel('Choose a date range').getByRole('button').click()
+ await page.getByRole('button', { name: /Today/ }).click()
+ await page.keyboard.press('Enter')
+ for (let i = 0; i < 6; i++) await page.keyboard.press('ArrowLeft')
+ await page.keyboard.press('Enter')
+ for (let i = 0; i < 4; i++) await page.keyboard.press('ArrowLeft')
+ await page.keyboard.press('Enter')
+ await page.keyboard.press('Escape')
+
+ await expect(page.getByText('No logs in selected time range')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Load More' })).toBeHidden()
+})