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 ( +
+
+ + {error} +
+ +
+ ) +} + +// deterministic random width generator for skeleton rows +const skeletonRando = new Rando(1543) +const randWidth = (target: number, range: number) => + target + (skeletonRando.next() * 2 - 1) * range +const skeletonRows = [...Array(50)].map(() => ({ + operation: `${randWidth(60, 10)}%`, + actorId: `${randWidth(80, 10)}%`, + authMethod: `${randWidth(60, 20)}%`, + siloId: `${randWidth(80, 10)}%`, + duration: `${randWidth(30, 10)}px`, +})) + +const LoadingState = () => { + return ( +
+ {/* Generate skeleton rows */} +
+ {skeletonRows.map((row, i) => ( +
+ {/* Time column */} +
+
+
+ + {/* Status column */} +
+
+
+ + {/* Operation column */} +
+
+
+ + {/* Actor ID column */} +
+
+
+ + {/* Auth Method column */} +
+
+
+ + {/* Silo ID column */} +
+
+
+ + {/* Duration column */} +
+
+
+
+ ))} +
+ + {/* Gradient fade overlay */} +
+
+ ) +} + +// names rarely change, and the pane switches items quickly under j/k, so don't +// refetch a silo or user we already looked up moments ago +const NAME_STALE_TIME = 10 * 60 * 1000 + +// 404 is the normal failure here: audit log entries outlive the users and +// silos they refer to +const notFoundExpected = (what: string) => ({ + explanation: `${what} may have been deleted since the entry was logged.`, + statusCode: 404, +}) + +type NameLookup = + | { type: 'pending' } + | { type: 'success'; name: string; to?: string } + | { type: 'error'; statusCode: number | undefined } + +const ResolvedName = ({ lookup }: { lookup: NameLookup }) => + match(lookup) + .with({ type: 'pending' }, () => ( +
+ )) + .with({ type: 'success', to: P.string }, ({ name, to }) => ( + + {name} + + )) + .with({ type: 'success' }, ({ name }) => <>{name}) + .with({ type: 'error', statusCode: 404 }, () => ( + Not found + )) + .with({ type: 'error' }, () => Unavailable) + .exhaustive() + +const SiloName = ({ siloId }: { siloId: string }) => { + const { data } = useQuery( + qErrorsAllowed( + api.siloView, + { path: { silo: siloId } }, + { errorsExpected: notFoundExpected('silo'), staleTime: NAME_STALE_TIME } + ) + ) + const lookup: NameLookup = !data + ? { type: 'pending' } + : data.type === 'success' + ? { type: 'success', name: data.data.name, to: pb.silo({ silo: data.data.name }) } + : { type: 'error', statusCode: data.data.statusCode } + return +} + +const SiloUserName = ({ userId, siloId }: { userId: string; siloId: string }) => { + const { data } = useQuery( + qErrorsAllowed( + api.siloUserView, + { path: { userId }, query: { silo: siloId } }, + { errorsExpected: notFoundExpected('user'), staleTime: NAME_STALE_TIME } + ) + ) + const lookup: NameLookup = !data + ? { type: 'pending' } + : data.type === 'success' + ? { type: 'success', name: data.data.displayName } + : { type: 'error', statusCode: data.data.statusCode } + return +} + +const BuiltinUserName = ({ userId }: { userId: string }) => { + const { data } = useQuery( + qErrorsAllowed( + api.userBuiltinView, + { path: { user: userId } }, + { errorsExpected: notFoundExpected('built-in user'), staleTime: NAME_STALE_TIME } + ) + ) + const lookup: NameLookup = !data + ? { type: 'pending' } + : data.type === 'success' + ? { type: 'success', name: data.data.name } + : { type: 'error', statusCode: data.data.statusCode } + return +} + +function StatusCodeCell({ code }: { code: number }) { + // 4xx is a failed request, but the client's fault rather than the system's + const color = code >= 500 ? 'destructive' : code >= 400 ? 'notice' : 'default' + return {code} +} + +// column widths and responsive hiding live in audit-log.css +const COLUMNS = [ + { title: 'Time Completed', className: 'col-time' }, + { title: 'Status', className: 'col-status' }, + { title: 'Operation', className: 'col-operation' }, + { title: 'Actor ID', className: 'col-actor-id' }, + { title: 'Auth Method', className: 'col-auth-method' }, + { title: 'Silo ID', className: 'col-silo-id' }, + { title: 'Duration (ms)', className: 'col-duration' }, +] as const + +const HeaderCell = classed.div`text-mono-sm text-tertiary` + +// shared so the virtualized rows don't each construct a formatter +const msFormat = new Intl.NumberFormat() + +// server default is 100. rows are virtualized and the response is small (a few +// hundred KB uncompressed at this size), so fewer Load More clicks wins +const PAGE_LIMIT = 500 + +type RowProps = { + log: AuditLogEntry + index: number + isExpanded: boolean + size: number + start: number + scrollMargin: 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`, or `scrollMargin` actually change re-render. +const Row = memo(function Row({ + log, + index, + isExpanded, + size, + start, + scrollMargin, + 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() + + return ( +
+
onToggle(index)} + onKeyDown={(e) => { + if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onToggle(index) + } + }} + // oxlint-disable-next-line prefer-tag-over-role -- row contains buttons (truncated copy) + role="button" + 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 ? ( + + ) : ( + + )} +
+
+ {msFormat.format( + differenceInMilliseconds(new Date(log.timeCompleted), log.timeStarted) + )} +
+
+
+ ) +}) + +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 = ( + <> +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => ( + + ))} +
+ {errorMessage !== null && ( + setErrorMessage(null)} /> + )} +
+ {settled ? ( +
+ {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]} + /> + + +
+ {dateTimeRangePicker} +
+
+ +
+
+
+
+ {COLUMNS.map((column) => ( + + {column.title} + + ))} +
+ {expandedItem !== null && + 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() + + return ( + setExpandedItem(null)} + /> + ) + })()} +
+ {loading ? : logTable} +
+
+ + ) +} + +const ExpandedItem = ({ + item, + userId, + siloId, + currentIndex, + totalCount, + onNavigate, + onClose, +}: { + item: AuditLogEntry + userId?: string + siloId?: string + currentIndex: number + totalCount: number + onNavigate: (index: number) => void + onClose: () => void +}) => { + // 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(() => snakeify(item) as JsonValue, [item]) + const json = useMemo(() => JSON.stringify(snakeJson, null, 2), [snakeJson]) + + return ( + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 286b165f6..952d7645c 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -273,6 +273,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/ui/styles/components/audit-log.css b/app/ui/styles/components/audit-log.css new file mode 100644 index 000000000..51c922466 --- /dev/null +++ b/app/ui/styles/components/audit-log.css @@ -0,0 +1,64 @@ +/* + * 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 + */ + +.audit-log-table { + container-type: inline-size; +} + +.audit-log-row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: max-content; + align-items: center; + gap: 2rem; +} + +.audit-log-row > .col-time { + width: 7.75rem; +} +.audit-log-row > .col-status { + width: 3rem; +} +.audit-log-row > .col-operation { + width: 10rem; +} +.audit-log-row > .col-actor-id { + width: 8.125rem; +} +.audit-log-row > .col-auth-method { + width: 7.5rem; +} +.audit-log-row > .col-silo-id { + width: 8.125rem; +} +.audit-log-row > .col-duration { + width: 5rem; + text-align: right; + white-space: nowrap; +} + +@container (width < 1025px) { + .audit-log-row > .col-silo-id { + display: none; + } +} +@container (width < 900px) { + .audit-log-row > .col-actor-id { + display: none; + } +} +@container (width < 700px) { + .audit-log-row > .col-duration { + display: none; + } +} +@container (width < 650px) { + .audit-log-row > .col-auth-method { + display: none; + } +} diff --git a/app/ui/styles/index.css b/app/ui/styles/index.css index d8d364fd7..d20107423 100644 --- a/app/ui/styles/index.css +++ b/app/ui/styles/index.css @@ -42,6 +42,7 @@ @import './components/menu-list.css' layer(components); @import './components/loading-bar.css' layer(components); @import './components/Tabs.css' layer(components); +@import './components/audit-log.css' layer(components); @import './components/form.css' layer(components); @import './components/login-page.css' layer(components); @import './components/side-modal.css' layer(components); diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 01f5aa12e..ab4e72670 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 d4021b24e..11426a1ab 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/operator/audit-log', + linkText: 'Audit Log', + }, deviceTokens: { href: 'https://docs.oxide.computer/guides/working-with-api-and-sdk#_device_token_setup', linkText: 'Access Tokens', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index d314b3b21..e407dba16 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 e09ad45aa..ca2660a31 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -149,6 +149,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/msw/rando.ts b/app/util/rando.ts similarity index 100% rename from mock-api/msw/rando.ts rename to app/util/rando.ts diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts new file mode 100644 index 000000000..18ca14782 --- /dev/null +++ b/mock-api/audit-log.ts @@ -0,0 +1,215 @@ +/* + * 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 { Rando } from '~/util/rando' + +import type { Json } from './json-type' +import { defaultSilo, myriadSilo } from './silo' +import { user1, user2 } from './user' + +// two real users (resolve to names in the detail pane) and two that don't exist +// (deleted users are a normal case in an audit log) +const mockUserIds = [ + user1.id, + user2.id, + 'c73bcdcc-2669-4bf6-81d3-e4ae73fb11fd', + '550e8400-e29b-41d4-a716-446655440000', +] + +const mockSiloIds = [myriadSilo.id, '7ba7b810-9dad-11d1-80b4-00c04fd430c8'] + +const mockOperations = [ + 'instance_create', + 'instance_delete', + 'anti_affinity_group_member_instance_add', + '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()) + +// Use seeded random for consistent states across runs +const rando = new Rando(0) + +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) // spread entries over time + + const completedTime = new Date(baseTime) + completedTime.setMilliseconds(rando.next() * 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(), + 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(), + 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(), + 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(), + 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(), + 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/disk.ts b/mock-api/disk.ts index d1bb98320..577cbddce 100644 --- a/mock-api/disk.ts +++ b/mock-api/disk.ts @@ -7,11 +7,11 @@ */ import type { Disk, DiskState } from '@oxide/api' +import { Rando } from '~/util/rando' import { GiB } from '~/util/units' import { instance, stoppedInstance } from './instance' import type { Json } from './json-type' -import { Rando } from './msw/rando' import { project, project2 } from './project' // Use seeded random for consistent states across runs 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 631f65173..90c51d537 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -622,6 +622,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 66eecbf02..6d1afb8c6 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1942,6 +1942,13 @@ export const handlers = makeHandlers({ requireFleetViewer(cookies) return lookup.silo(path) }, + siloUserView({ path, query, cookies }) { + requireFleetViewer(cookies) + const silo = lookup.silo({ silo: query.silo }) + const user = db.users.find((u) => u.id === path.userId && u.silo_id === silo.id) + if (!user) throw notFoundErr(`user '${path.userId}'`) + return user + }, siloDelete({ path, cookies }) { requireFleetViewer(cookies) const silo = lookup.silo(path) @@ -2326,6 +2333,22 @@ export const handlers = makeHandlers({ ) return paginated(query, affinityGroups) }, + auditLogList: ({ query, cookies }) => { + requireFleetViewer(cookies) + + // same semantics as Nexus: start_time <= time_completed < end_time + // https://github.com/oxidecomputer/omicron/blob/17e6fee/nexus/db-queries/src/db/datastore/audit_log.rs + const { startTime, endTime } = query + let filteredLogs = db.auditLog + if (startTime) { + filteredLogs = filteredLogs.filter((log) => new Date(log.time_completed) >= startTime) + } + if (endTime) { + filteredLogs = filteredLogs.filter((log) => new Date(log.time_completed) < endTime) + } + + return paginated(query, filteredLogs) + }, // SCIM token endpoints scimTokenList({ query, cookies }) { @@ -2657,7 +2680,6 @@ export const handlers = makeHandlers({ alertReceiverView: NotImplemented, alertView: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, - auditLogList: NotImplemented, certificateCreate: NotImplemented, certificateDelete: NotImplemented, certificateList: NotImplemented, @@ -2735,7 +2757,6 @@ export const handlers = makeHandlers({ siloPolicyUpdate: NotImplemented, siloPolicyView: NotImplemented, siloUserList: NotImplemented, - siloUserView: NotImplemented, sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, supportBundleCreate: NotImplemented, diff --git a/mock-api/msw/util.spec.ts b/mock-api/msw/util.spec.ts index d1638c67d..f528cca9e 100644 --- a/mock-api/msw/util.spec.ts +++ b/mock-api/msw/util.spec.ts @@ -17,7 +17,8 @@ describe('paginated', () => { const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] const page = paginated({}, items) expect(page.items).toEqual([{ id: 'a' }, { id: 'b' }, { id: 'c' }]) - expect(page.next_page).toBeNull() + // like Dropshot, a page with items always gets a token, even the last one + expect(page.next_page).toBe('c') }) it('should return the first 100 items with no limit passed', () => { @@ -25,10 +26,10 @@ describe('paginated', () => { const page = paginated({}, items) expect(page.items.length).toBe(100) expect(page.items).toEqual(items.slice(0, 100)) - expect(page.next_page).toBe('i100') + expect(page.next_page).toBe('i99') }) - it('should return page with null `next_page` if items equal page', () => { + it('should return a token when the items exactly fill a page', () => { const items = [ { id: 'a' }, { id: 'b' }, @@ -44,7 +45,7 @@ describe('paginated', () => { const page = paginated({}, items) expect(page.items.length).toBe(10) expect(page.items).toEqual(items.slice(0, 10)) - expect(page.next_page).toBeNull() + expect(page.next_page).toBe('j') }) it('should return 5 items with a limit of 5', () => { @@ -59,14 +60,20 @@ describe('paginated', () => { const page = paginated({ limit: 5 }, items) expect(page.items.length).toBe(5) expect(page.items).toEqual(items.slice(0, 5)) - expect(page.next_page).toBe('f') + expect(page.next_page).toBe('e') }) - it('should return the second page when given a `page_token`', () => { + it('should start the next page after the token item', () => { const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }] const page = paginated({ pageToken: 'b' }, items) - expect(page.items.length).toBe(3) - expect(page.items).toEqual([{ id: 'b' }, { id: 'c' }, { id: 'd' }]) + expect(page.items).toEqual([{ id: 'c' }, { id: 'd' }]) + expect(page.next_page).toBe('d') + }) + + it('should return an empty page with no token after the last item', () => { + const items = [{ id: 'a' }, { id: 'b' }] + const page = paginated({ pageToken: 'b' }, items) + expect(page.items).toEqual([]) expect(page.next_page).toBeNull() }) }) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index a199ba086..042f4381e 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -36,6 +36,7 @@ import { import { json, type Json } from '~/api/__generated__/msw-handlers' import type { OxqlNetworkMetricName, OxqlVcpuState } from '~/components/oxql-metrics/util' import { parseIp } from '~/util/ip' +import { Rando } from '~/util/rando' import { GiB, TiB } from '~/util/units' import type { DbRoleAssignmentResourceType } from '..' @@ -43,7 +44,6 @@ import { SENTINEL_FLAT_INSTANCE_ID, SENTINEL_SLOPE_INSTANCE_ID } from '../instan import { genI64Data } from '../metrics' import { getMockOxqlInstanceData } from '../oxql-metrics' import { db, lookupById } from './db' -import { Rando } from './rando' interface PaginateOptions { limit?: number | null @@ -54,6 +54,14 @@ export interface ResultsPage { next_page: string | null } +/** + * Page through `items` the way Dropshot does: the token names the last item of + * the page it came from, the next page starts after it, and every page with + * items gets a token, including the last one, which then leads to an empty + * page. Clients can't learn they've hit the end from the token alone, only + * from a page shorter than `limit` (or empty). + * https://github.com/oxidecomputer/dropshot/blob/4ff9cb3/dropshot/src/pagination.rs#L168-L176 + */ export const paginated =

( params: P, items: I[] @@ -61,26 +69,14 @@ export const paginated =

( 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() +})