diff --git a/app/hooks/use-window-size.ts b/app/hooks/use-window-size.ts
new file mode 100644
index 000000000..1fd7aad09
--- /dev/null
+++ b/app/hooks/use-window-size.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 { useEffect, useState } from 'react'
+
+export const useWindowSize = () => {
+ const [size, setSize] = useState<{
+ width: number
+ height: number
+ }>({
+ width: 0,
+ height: 0,
+ })
+
+ // Handler to call on window resize
+ function handleResize() {
+ // Set window width/height to state
+ setSize({
+ width: window.innerWidth,
+ height: window.innerHeight,
+ })
+ }
+
+ useEffect(() => {
+ // Only execute all the code below in client side
+ if (typeof window !== 'undefined') {
+ window.addEventListener('resize', handleResize)
+
+ handleResize()
+
+ return () => window.removeEventListener('resize', handleResize)
+ }
+ }, [])
+
+ return size
+}
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/layouts/helpers.tsx b/app/layouts/helpers.tsx
index ffdab7970..c47a16608 100644
--- a/app/layouts/helpers.tsx
+++ b/app/layouts/helpers.tsx
@@ -27,7 +27,7 @@ export function ContentPane() {
-
+
diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx
new file mode 100644
index 000000000..5496dca06
--- /dev/null
+++ b/app/pages/system/AuditLog.tsx
@@ -0,0 +1,818 @@
+/*
+ * 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, useIsFetching } 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 { match, P } from 'ts-pattern'
+import { type JsonValue } from 'type-fest'
+
+import { api, 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 { DocsPopover } from '~/components/DocsPopover'
+import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker'
+import { useIntervalPicker } from '~/components/RefetchIntervalPicker'
+import { useWindowSize } from '~/hooks/use-window-size'
+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 { deterRandom } from '~/util/math'
+
+export const handle = { crumb: 'Audit Log' }
+
+/**
+ * Convert API response JSON from the camel-cased version we get out of the TS
+ * client back into snake-case, which is what we get from the API. This is truly
+ * stupid but I can't think of a better way.
+ */
+function camelToSnakeJson(o: Record
): Record {
+ const result: Record = {}
+
+ if (o instanceof Date) return o
+
+ for (const originalKey in o) {
+ if (!Object.prototype.hasOwnProperty.call(o, originalKey)) {
+ continue
+ }
+
+ const snakeKey = originalKey
+ .replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
+ .replace(/^_/, '')
+ const value = o[originalKey]
+
+ if (value !== null && typeof value === 'object') {
+ if (Array.isArray(value)) {
+ result[snakeKey] = value.map((item) =>
+ item !== null && typeof item === 'object' && !Array.isArray(item)
+ ? camelToSnakeJson(item as Record)
+ : item
+ )
+ } else {
+ result[snakeKey] = camelToSnakeJson(value as Record)
+ }
+ } else {
+ result[snakeKey] = value
+ }
+ }
+
+ return result
+}
+
+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 (
+
+ )
+}
+
+const LoadingState = () => {
+ return (
+
+ {/* Generate skeleton rows */}
+
+ {[...Array(50)].map((_, i) => (
+
+ {/* Time column */}
+
+
+ {/* Status column */}
+
+
+ {/* Operation column */}
+
+
+ {/* Actor ID column */}
+
+
+ {/* Auth Method column */}
+
+
+ {/* Silo ID column */}
+
+
+ {/* Duration column */}
+
+
+ ))}
+
+
+ {/* Gradient fade overlay */}
+
+
+ )
+}
+
+function StatusCodeCell({ code }: { code: number }) {
+ const color = code >= 200 && code < 500 ? 'default' : 'destructive'
+ return {code}
+}
+
+const COLUMNS = [
+ { key: 'timeCompleted', title: 'Time Completed', width: '7.75rem', hideBelow: 0 },
+ { key: 'status', title: 'Status', width: '3rem', hideBelow: 0 },
+ { key: 'operation', title: 'Operation', width: '160px', hideBelow: 0 },
+ { key: 'actorId', title: 'Actor ID', width: '130px', hideBelow: 1150 },
+ {
+ key: 'authMethod',
+ title: 'Auth Method',
+ width: '120px',
+ hideBelow: 875,
+ },
+ { key: 'siloId', title: 'Silo ID', width: '130px', hideBelow: 1250 },
+ { key: 'duration', title: 'Duration', width: '1fr', hideBelow: 950 },
+] as const
+
+const getResponsiveColWidths = () => ({
+ gridTemplateColumns: COLUMNS.map((col) => col.width).join(' '),
+})
+
+const colWidths = getResponsiveColWidths()
+
+const HeaderCell = classed.div`text-mono-sm text-tertiary`
+
+type RowProps = {
+ log: AuditLogEntry
+ index: number
+ isExpanded: boolean
+ size: number
+ start: number
+ scrollMargin: number
+ screenWidth: number
+ onToggle: (index: number) => void
+}
+
+// memoized so a parent re-render (scroll, keydown, selection change) doesn't
+// re-run the per-row Tooltip / CopyToClipboard / Badge / ts-pattern work for
+// every virtualized row. Props are referentially stable per row, so only rows
+// whose `isExpanded`, `start`, `scrollMargin`, or `screenWidth` actually
+// change re-render.
+const Row = memo(function Row({
+ log,
+ index,
+ isExpanded,
+ size,
+ start,
+ scrollMargin,
+ screenWidth,
+ onToggle,
+}: RowProps) {
+ const [userId, siloId] = match(log.actor)
+ .with({ kind: 'silo_user' }, (actor) => [actor.siloUserId, actor.siloId])
+ .with({ kind: 'user_builtin' }, (actor) => [actor.userBuiltinId, undefined])
+ .with({ kind: 'scim' }, (actor) => [undefined, actor.siloId])
+ .with({ kind: 'unauthenticated' }, () => [undefined, undefined])
+ .exhaustive()
+
+ // breakpoints come from COLUMNS[].hideBelow
+ const hideActorId = screenWidth < 1150
+ const hideAuthMethod = screenWidth < 875
+ const hideSiloId = screenWidth < 1250
+ const hideDuration = screenWidth < 950
+
+ return (
+
+
onToggle(index)}
+ // TODO: some of the focusing behaviour and repetitive code needs work
+ // a11y thing: make it focusable and let the user press enter on it to toggle
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ onToggle(index)
+ }
+ }}
+ role="button" // oxlint-disable-line prefer-tag-over-role
+ tabIndex={0}
+ data-row-index={index}
+ >
+ {/* TODO: might be especially useful here to get the original UTC timestamp in a tooltip */}
+
+ {toSyslogDateString(log.timeCompleted)} {' '}
+ {toSyslogTimeString(log.timeCompleted)}
+
+
+ {match(log.result)
+ .with(P.union({ kind: 'success' }, { kind: 'error' }), (result) => (
+
+ ))
+ .with({ kind: 'unknown' }, () => )
+ .exhaustive()}
+
+
+ {log.operationId.split('_').join(' ')}
+
+
+ {userId ? (
+
+ ) : (
+
+ )}
+
+
+ {log.authMethod ? (
+ {log.authMethod.split('_').join(' ')}
+ ) : (
+
+ )}
+
+
+ {siloId ? (
+
+ ) : (
+
+ )}
+
+
+ {differenceInMilliseconds(new Date(log.timeCompleted), log.timeStarted)}
+ ms
+
+
+
+ )
+})
+
+export default function SiloAuditLogsPage() {
+ const [expandedItem, setExpandedItem] = useState(null)
+ const [dismissedError, setDismissedError] = useState(false)
+
+ // pass refetch interval to this to keep the date up to date
+ const { preset, startTime, endTime, dateTimeRangePicker, onRangeChange } =
+ useDateTimeRangePicker({
+ initialPreset: 'lastHour',
+ maxValue: now(getLocalTimeZone()),
+ })
+
+ const { intervalPicker } = useIntervalPicker({
+ enabled: preset !== 'custom',
+ isLoading: useIsFetching({ queryKey: ['auditLogList'] }) > 0,
+ // sliding the range forward is sufficient to trigger a refetch
+ fn: () => onRangeChange(preset),
+ })
+
+ const queryParams: AuditLogListQueryParams = {
+ startTime,
+ endTime,
+ sortBy: 'time_and_id_descending',
+ }
+
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ isPending,
+ isFetching,
+ error,
+ } = useInfiniteQuery({
+ queryKey: ['auditLogList', { query: queryParams }],
+ queryFn: ({ pageParam }) =>
+ api
+ .auditLogList({ query: { ...queryParams, pageToken: pageParam } })
+ .then((result) => {
+ if (result.type === 'success') return result.data
+ throw result
+ }),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) => lastPage.nextPage || undefined,
+ placeholderData: (x) => x,
+ })
+
+ // resetting the error if the query params change
+ useEffect(() => {
+ setDismissedError(false)
+ }, [startTime, endTime, preset])
+
+ const allItems = useMemo(() => {
+ return data?.pages.flatMap((page) => page.items) || []
+ }, [data])
+
+ 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,
+ })
+
+ const handleToggle = useCallback((index: string | null) => {
+ setExpandedItem(index)
+ }, [])
+
+ // Row receives a stable callback that takes a number — keeps the memoized
+ // Row from re-rendering when its only changing prop would be the onClick
+ // closure
+ const selectRow = useCallback((index: number) => {
+ setExpandedItem(index.toString())
+ }, [])
+
+ // 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
+ handleToggle(newIndex.toString())
+ scrollToRow(newIndex)
+ },
+ [allItems.length, handleToggle, scrollToRow]
+ )
+
+ const focusRow = useCallback((index: number) => {
+ parentRef.current
+ ?.querySelector(`[data-row-index="${index}"]`)
+ ?.focus({ preventScroll: true })
+ }, [])
+
+ // arrow keys move selection (and focus) between rows; escape closes the
+ // modal. adjacent rows are within the virtualizer's overscan, so they're
+ // already in the DOM when we look them up.
+ useEffect(() => {
+ if (expandedItem === null) return
+ 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
+
+ const currentIdx = parseInt(expandedItem, 10)
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ const next = currentIdx + 1
+ if (next < allItems.length) {
+ navigateToIndex(next)
+ focusRow(next)
+ }
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ const prev = currentIdx - 1
+ if (prev >= 0) {
+ navigateToIndex(prev)
+ focusRow(prev)
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ handleToggle(null)
+ // restore focus to the row in case focus was inside the modal
+ focusRow(currentIdx)
+ }
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [expandedItem, allItems.length, handleToggle, navigateToIndex, focusRow])
+
+ const screenSize = useWindowSize()
+
+ const logTable = (
+ <>
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => (
+
+ ))}
+
+
+ {!hasNextPage && !isFetching && !isPending && allItems.length > 0 ? (
+
+ No more logs to show within selected timeline
+
+ ) : (
+
fetchNextPage()}
+ disabled={isFetchingNextPage}
+ type="button"
+ loading={isFetchingNextPage}
+ >
+ Load More
+
+ )}
+
+ >
+ )
+
+ const selectedItem = expandedItem ? allItems[parseInt(expandedItem, 10)] : null
+
+ const errorMessage = error?.message ?? 'An error occurred while loading audit logs'
+ const showError = error && !dismissedError
+
+ // 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)
+ }
+ }, [showError, isLoading])
+
+ return (
+ <>
+
+
+ }>Audit Log
+ }
+ summary="The audit log provides a record of system activities, including user actions, API calls, and system events."
+ links={[docLinks.auditLog]}
+ />
+
+
+
+ {intervalPicker}
+
{dateTimeRangePicker}
+
+
+
+
+
+
+
+ {COLUMNS.map((column) => (
+
+ {column.title}
+
+ ))}
+
+ {selectedItem &&
+ (() => {
+ const [userId, siloId] = match(selectedItem.actor)
+ .with({ kind: 'silo_user' }, (actor) => [actor.siloUserId, actor.siloId])
+ .with({ kind: 'user_builtin' }, (actor) => [
+ actor.userBuiltinId,
+ undefined,
+ ])
+ .with({ kind: 'scim' }, (actor) => [undefined, actor.siloId])
+ .with({ kind: 'unauthenticated' }, () => [undefined, undefined])
+ .exhaustive()
+
+ const currentIndex = parseInt(expandedItem!, 10)
+
+ return (
+
handleToggle(null)}
+ />
+ )
+ })()}
+
+ {showError && (
+
setDismissedError(true)} />
+ )}
+ {!isLoading ? logTable : }
+
+
+ >
+ )
+}
+
+const ExpandedItem = ({
+ item,
+ userId,
+ siloId,
+ currentIndex,
+ totalCount,
+ onNavigate,
+ onClose,
+ hasError = false,
+}: {
+ item: AuditLogEntry
+ userId?: string
+ siloId?: string
+ currentIndex: number
+ totalCount: number
+ onNavigate: (index: number) => void
+ onClose: () => void
+ hasError: boolean
+}) => {
+ // recomputing these on every parent re-render (e.g. on scroll) would be
+ // wasted work — and would also defeat HighlightJSON's memo by passing a new
+ // object identity each time
+ const snakeJson = useMemo(() => camelToSnakeJson(item), [item])
+ const json = useMemo(() => JSON.stringify(snakeJson, null, 2), [snakeJson])
+
+ return (
+
+
+
+
currentIndex > 0 && onNavigate(currentIndex - 1)}
+ disabled={currentIndex === 0}
+ className="hover:bg-hover disabled:hover:bg-raise flex h-6 w-6 flex-shrink-0 rotate-90 items-center justify-center rounded disabled:cursor-default disabled:opacity-50"
+ >
+ {/* support arrow keys and keep centered autoscroll to element */}
+
+
+
currentIndex < totalCount - 1 && onNavigate(currentIndex + 1)}
+ disabled={currentIndex === totalCount - 1}
+ className="hover:bg-hover disabled:hover:bg-raise flex h-6 w-6 flex-shrink-0 rotate-90 items-center justify-center rounded disabled:cursor-default disabled:opacity-50"
+ >
+
+
+
+ {item.operationId.split('_').join(' ')}
+
+ {match(item.result)
+ .with(P.union({ kind: 'success' }, { kind: 'error' }), (result) => (
+
+ ))
+ .with({ kind: 'unknown' }, () =>
)
+ .exhaustive()}
+
+
+
+
+
+
+
+
+
+
+ {toLocaleDateString(item.timeCompleted)}{' '}
+
+ {toSyslogTimeString(item.timeCompleted)}
+
+
+
+
+
+ {userId ? (
+
+ ) : (
+
+ )}
+
+
+
+ {item.authMethod ? (
+ {item.authMethod.split('_').join(' ')}
+ ) : (
+
+ )}
+
+
+
+ {siloId ? (
+
+ ) : (
+
+ )}
+
+
+
+ {differenceInMilliseconds(new Date(item.timeCompleted), item.timeStarted)}ms
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/routes.tsx b/app/routes.tsx
index 5d07ce94b..d0a38d561 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -266,6 +266,10 @@ export const routes = createRoutesFromElements(
path="access"
lazy={() => import('./pages/system/FleetAccessPage').then(convert)}
/>
+ import('./pages/system/AuditLog').then(convert)}
+ />
redirect(pb.projects())} element={null} />
diff --git a/app/ui/lib/CopyToClipboard.tsx b/app/ui/lib/CopyToClipboard.tsx
index a4d2934ff..fcaec6935 100644
--- a/app/ui/lib/CopyToClipboard.tsx
+++ b/app/ui/lib/CopyToClipboard.tsx
@@ -22,7 +22,7 @@ type Props = {
}
const variants = {
- hidden: { opacity: 0, scale: 0.75 },
+ hidden: { opacity: 0, scale: 0.85 },
visible: { opacity: 1, scale: 1 },
}
diff --git a/app/ui/lib/DatePicker.tsx b/app/ui/lib/DatePicker.tsx
index 589ab49d2..36cc31801 100644
--- a/app/ui/lib/DatePicker.tsx
+++ b/app/ui/lib/DatePicker.tsx
@@ -55,7 +55,7 @@ export function DatePicker(props: DatePickerProps) {
type="button"
className={cn(
state.isOpen && 'z-10 ring-2',
- 'text-sans-md border-default hover:border-raise bg-default relative flex h-11 items-center rounded-l-md rounded-r-md border focus-within:ring-2 focus:z-10',
+ 'text-sans-md border-default hover:border-raise bg-default relative flex h-10 items-center rounded-l-md rounded-r-md border focus-within:ring-2 focus:z-10',
state.isInvalid
? 'focus-error border-error ring-error-secondary'
: 'border-default ring-accent-secondary'
diff --git a/app/ui/lib/DateRangePicker.tsx b/app/ui/lib/DateRangePicker.tsx
index d7a328451..78d660093 100644
--- a/app/ui/lib/DateRangePicker.tsx
+++ b/app/ui/lib/DateRangePicker.tsx
@@ -63,7 +63,7 @@ export function DateRangePicker(props: DateRangePickerProps) {
type="button"
className={cn(
state.isOpen && 'z-10 ring-2',
- 'text-sans-md border-default hover:border-raise bg-default relative flex h-11 items-center rounded-l-md rounded-r-md border focus-within:ring-2 focus:z-10',
+ 'text-sans-md border-default hover:border-raise bg-default relative flex h-10 items-center rounded-l-md rounded-r-md border focus-within:ring-2 focus:z-10',
state.isInvalid
? 'focus-error border-error ring-error-secondary hover:border-error'
: 'border-default ring-accent-secondary'
diff --git a/app/ui/lib/Listbox.tsx b/app/ui/lib/Listbox.tsx
index 1b419123c..39e8208ea 100644
--- a/app/ui/lib/Listbox.tsx
+++ b/app/ui/lib/Listbox.tsx
@@ -104,7 +104,7 @@ export const Listbox = ({
id={id}
name={name}
className={cn(
- `text-sans-md flex h-11 items-center justify-between rounded-md border`,
+ `text-sans-md flex h-10 items-center justify-between rounded-md border`,
hasError
? 'focus-error border-error-secondary hover:border-error'
: 'border-default hover:border-raise',
diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap
index 0ac260b92..9c995947b 100644
--- a/app/util/__snapshots__/path-builder.spec.ts.snap
+++ b/app/util/__snapshots__/path-builder.spec.ts.snap
@@ -76,6 +76,12 @@ exports[`breadcrumbs 2`] = `
"path": "/projects/p/affinity/aag",
},
],
+ "auditLog (/system/audit-log)": [
+ {
+ "label": "Audit Log",
+ "path": "/system/audit-log",
+ },
+ ],
"deviceSuccess (/device/success)": [],
"disk (/projects/p/disks/d)": [
{
diff --git a/app/util/date.ts b/app/util/date.ts
index 9f504267d..81aa17e16 100644
--- a/app/util/date.ts
+++ b/app/util/date.ts
@@ -53,3 +53,19 @@ export const toLocaleTimeString = (d: Date, locale?: string) =>
export const toLocaleDateTimeString = (d: Date, locale?: string) =>
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d)
+
+// `Jan 21`
+export const toSyslogDateString = (d: Date, locale?: string) =>
+ new Intl.DateTimeFormat(locale, {
+ month: 'short',
+ day: 'numeric',
+ }).format(d)
+
+// `23:33:45`
+export const toSyslogTimeString = (d: Date, locale?: string) =>
+ new Intl.DateTimeFormat(locale, {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ }).format(d)
diff --git a/app/util/links.ts b/app/util/links.ts
index 7c9fcfbf5..e41e9ebdf 100644
--- a/app/util/links.ts
+++ b/app/util/links.ts
@@ -40,6 +40,10 @@ export const docLinks = {
href: 'https://docs.oxide.computer/guides/deploying-workloads#_affinity_and_anti_affinity',
linkText: 'Anti-Affinity Groups',
},
+ auditLog: {
+ href: 'https://docs.oxide.computer/guides/audit-logs',
+ linkText: 'Audit Logs',
+ },
deviceTokens: {
href: 'https://docs.oxide.computer/guides/working-with-api-and-sdk#_device_token_setup',
linkText: 'Access Tokens',
diff --git a/app/util/math.ts b/app/util/math.ts
index d240fa9ad..a4bbbe381 100644
--- a/app/util/math.ts
+++ b/app/util/math.ts
@@ -112,3 +112,10 @@ export function diskSizeNearest10(imageSizeGiB: number) {
const nearest10 = Math.ceil(imageSizeGiB / 10) * 10
return Math.min(nearest10, MAX_DISK_SIZE_GiB)
}
+
+export function deterRandom(i: number, target: number, range: number) {
+ const variation =
+ (Math.sin(i * 0.7) * 1.0 + Math.sin(i * 1.3) * 0.75 + Math.sin(i * 2.1) * 0.5) / 2.25 // Normalize to approximately [-1, 1]
+
+ return target + variation * range
+}
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index ebd7d3e3d..a8ce07be4 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -49,6 +49,7 @@ test('path builder', () => {
"affinityNew": "/projects/p/affinity-new",
"antiAffinityGroup": "/projects/p/affinity/aag",
"antiAffinityGroupEdit": "/projects/p/affinity/aag/edit",
+ "auditLog": "/system/audit-log",
"deviceSuccess": "/device/success",
"disk": "/projects/p/disks/d",
"diskInventory": "/system/inventory/disks",
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index 468a4e714..d0132d8d4 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -150,6 +150,8 @@ export const pb = {
systemUpdate: () => '/system/update',
+ auditLog: () => '/system/audit-log',
+
profile: () => '/settings/profile',
sshKeys: () => '/settings/ssh-keys',
sshKeysNew: () => '/settings/ssh-keys-new',
diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts
new file mode 100644
index 000000000..8e4e31c99
--- /dev/null
+++ b/mock-api/audit-log.ts
@@ -0,0 +1,212 @@
+/*
+ * 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 { v4 as uuid } from 'uuid'
+
+import type { AuditLogEntry } from '@oxide/api'
+
+import type { Json } from './json-type'
+import { defaultSilo } from './silo'
+
+const mockUserIds = [
+ 'a47ac10b-58cc-4372-a567-0e02b2c3d479',
+ '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
+ 'c73bcdcc-2669-4bf6-81d3-e4ae73fb11fd',
+ '550e8400-e29b-41d4-a716-446655440000',
+]
+
+const mockSiloIds = [
+ 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ '7ba7b810-9dad-11d1-80b4-00c04fd430c8',
+]
+
+const mockOperations = [
+ 'instance_create',
+ 'instance_delete',
+ 'instance_start',
+ 'instance_stop',
+ 'instance_reboot',
+ 'project_create',
+ 'project_delete',
+ 'project_update',
+ 'disk_create',
+ 'disk_delete',
+ 'disk_attach',
+ 'disk_detach',
+ 'image_create',
+ 'image_delete',
+ 'image_promote',
+ 'image_demote',
+ 'vpc_create',
+ 'vpc_delete',
+ 'vpc_update',
+ 'floating_ip_create',
+ 'floating_ip_delete',
+ 'floating_ip_attach',
+ 'floating_ip_detach',
+ 'snapshot_create',
+ 'snapshot_delete',
+ 'silo_create',
+ 'silo_delete',
+ 'user_login',
+ 'user_logout',
+ 'ssh_key_create',
+ 'ssh_key_delete',
+]
+
+const mockAuthMethod: Json['auth_method'][] = [
+ 'session_cookie',
+ 'access_token',
+ 'scim_token',
+ null,
+]
+
+const mockHttpStatusCodes = [200, 201, 204, 400, 401, 403, 404, 409, 500, 502, 503]
+
+const mockSourceIps = [
+ '192.168.1.100',
+ '10.0.0.50',
+ '172.16.0.25',
+ '203.0.113.15',
+ '198.51.100.42',
+]
+
+const mockRequestIds = Array.from({ length: 20 }, () => uuid())
+
+function generateAuditLogEntry(index: number): Json {
+ const operation = mockOperations[index % mockOperations.length]
+ const statusCode = mockHttpStatusCodes[index % mockHttpStatusCodes.length]
+ const isError = statusCode >= 400
+ const baseTime = new Date()
+ baseTime.setSeconds(baseTime.getSeconds() - index * 5 * 1) // Spread entries over time
+
+ const completedTime = new Date(baseTime)
+ completedTime.setMilliseconds(
+ Math.abs(Math.sin(index)) * 300 + completedTime.getMilliseconds()
+ ) // Deterministic random durations
+
+ return {
+ id: uuid(),
+ auth_method: mockAuthMethod[index % mockAuthMethod.length],
+ actor: {
+ kind: 'silo_user',
+ silo_id: defaultSilo.id,
+ silo_user_id: mockUserIds[index % mockUserIds.length],
+ },
+ result: isError
+ ? {
+ kind: 'error',
+ error_code: `E${statusCode}`,
+ error_message: `Operation failed with status ${statusCode}`,
+ http_status_code: statusCode,
+ }
+ : { kind: 'success', http_status_code: statusCode },
+ operation_id: operation,
+ request_id: mockRequestIds[index % mockRequestIds.length],
+ time_started: baseTime.toISOString(),
+ time_completed: completedTime.toISOString(),
+ request_uri: `https://maze-war.sys.corp.rack/v1/projects/default/${operation.replace('_', '/')}`,
+ source_ip: mockSourceIps[index % mockSourceIps.length],
+ }
+}
+
+export const auditLog: Json = [
+ // Recent successful operations
+ {
+ id: uuid(),
+ auth_method: 'session_cookie',
+ actor: {
+ kind: 'silo_user',
+ silo_id: defaultSilo.id,
+ silo_user_id: mockUserIds[0],
+ },
+ result: { kind: 'success', http_status_code: 201 },
+ operation_id: 'instance_create',
+ request_id: mockRequestIds[0],
+ time_started: new Date(Date.now() - 1000 * 60 * 5).toISOString(), // 5 minutes ago
+ time_completed: new Date(Date.now() - 1000 * 60 * 5 + 321).toISOString(), // 1 second later
+ request_uri: 'https://maze-war.sys.corp.rack/v1/projects/admin-project/instances',
+ source_ip: '192.168.1.100',
+ },
+ {
+ id: uuid(),
+ auth_method: 'access_token',
+ actor: {
+ kind: 'silo_user',
+ silo_id: defaultSilo.id,
+ silo_user_id: mockUserIds[1],
+ },
+ result: { kind: 'success', http_status_code: 200 },
+ operation_id: 'instance_start',
+ request_id: mockRequestIds[1],
+ time_started: new Date(Date.now() - 1000 * 60 * 10).toISOString(), // 10 minutes ago
+ time_completed: new Date(Date.now() - 1000 * 60 * 10 + 126).toISOString(), // 1 second later
+ request_uri:
+ 'https://maze-war.sys.corp.rack/v1/projects/admin-project/instances/web-server-prod/start',
+ source_ip: '10.0.0.50',
+ },
+ // Failed operations
+ {
+ id: uuid(),
+ auth_method: 'session_cookie',
+ actor: {
+ kind: 'silo_user',
+ silo_id: mockSiloIds[1],
+ silo_user_id: mockUserIds[2],
+ },
+ result: {
+ kind: 'error',
+ error_code: 'E403',
+ error_message: 'Insufficient permissions to delete instance',
+ http_status_code: 403,
+ },
+ operation_id: 'instance_delete',
+ request_id: mockRequestIds[2],
+ time_started: new Date(Date.now() - 1000 * 60 * 15).toISOString(), // 15 minutes ago
+ time_completed: new Date(Date.now() - 1000 * 60 * 15 + 147).toISOString(), // 1 second later
+ request_uri:
+ 'https://maze-war.sys.corp.rack/v1/projects/dev-project/instances/test-instance',
+ source_ip: '172.16.0.25',
+ },
+ {
+ id: uuid(),
+ auth_method: null,
+ actor: { kind: 'unauthenticated' },
+ result: {
+ kind: 'error',
+ error_code: 'E401',
+ error_message: 'Authentication required',
+ http_status_code: 401,
+ },
+ operation_id: 'user_login',
+ request_id: mockRequestIds[3],
+ time_started: new Date(Date.now() - 1000 * 60 * 20).toISOString(), // 20 minutes ago
+ time_completed: new Date(Date.now() - 1000 * 60 * 20 + 16).toISOString(), // 1 second later
+ request_uri: 'https://maze-war.sys.corp.rack/v1/login',
+ source_ip: '203.0.113.15',
+ },
+ // More historical entries
+ {
+ id: uuid(),
+ auth_method: 'session_cookie',
+ actor: {
+ kind: 'silo_user',
+ silo_id: mockSiloIds[0],
+ silo_user_id: mockUserIds[0],
+ },
+ result: { kind: 'success', http_status_code: 201 },
+ operation_id: 'project_create',
+ request_id: mockRequestIds[4],
+ time_started: new Date(Date.now() - 1000 * 60 * 60).toISOString(), // 1 hour ago
+ time_completed: new Date(Date.now() - 1000 * 60 * 60 + 36).toISOString(), // 1 second later
+ request_uri: 'https://maze-war.sys.corp.rack/v1/projects',
+ source_ip: '192.168.1.100',
+ },
+ // Generate additional entries
+ ...Array.from({ length: 4995 }, (_, i) => generateAuditLogEntry(i + 5)),
+]
diff --git a/mock-api/index.ts b/mock-api/index.ts
index 3620d30c2..b87be9bc8 100644
--- a/mock-api/index.ts
+++ b/mock-api/index.ts
@@ -7,6 +7,7 @@
*/
export * from './affinity-group'
+export * from './audit-log'
export * from './disk'
export * from './external-ip'
export * from './external-subnet'
diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts
index 9986205ed..ade9a5538 100644
--- a/mock-api/msw/db.ts
+++ b/mock-api/msw/db.ts
@@ -606,6 +606,7 @@ const initDb = {
affinityGroupMemberLists: [...mock.affinityGroupMemberLists],
antiAffinityGroups: [...mock.antiAffinityGroups],
antiAffinityGroupMemberLists: [...mock.antiAffinityGroupMemberLists],
+ auditLog: [...mock.auditLog],
deviceTokens: [...mock.deviceTokens],
disks: [...mock.disks],
diskBulkImportState: new Map(),
diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts
index 9f00f10ca..b16b03174 100644
--- a/mock-api/msw/handlers.ts
+++ b/mock-api/msw/handlers.ts
@@ -2265,6 +2265,23 @@ export const handlers = makeHandlers({
)
return paginated(query, affinityGroups)
},
+ auditLogList: ({ query }) => {
+ let filteredLogs = db.auditLog
+
+ if (query.startTime) {
+ filteredLogs = filteredLogs.filter(
+ (log) => new Date(log.time_completed) >= query.startTime!
+ )
+ }
+
+ if (query.endTime) {
+ filteredLogs = filteredLogs.filter(
+ (log) => new Date(log.time_completed) < query.endTime!
+ )
+ }
+
+ return paginated(query, filteredLogs)
+ },
// SCIM token endpoints
scimTokenList({ query, cookies }) {
@@ -2566,7 +2583,6 @@ export const handlers = makeHandlers({
alertReceiverSubscriptionRemove: NotImplemented,
alertReceiverView: NotImplemented,
antiAffinityGroupMemberInstanceView: NotImplemented,
- auditLogList: NotImplemented,
certificateCreate: NotImplemented,
certificateDelete: NotImplemented,
certificateList: NotImplemented,