diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 69ff5828e..9f15dc332 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -12,6 +12,7 @@ import { apiQueryClient } from '@oxide/api' import { Cloud16Icon, IpGlobal16Icon, + Logs16Icon, Metrics16Icon, Servers16Icon, } from '@oxide/design-system/icons/react' @@ -63,6 +64,7 @@ export default function SystemLayout() { { value: 'Utilization', path: pb.systemUtilization() }, { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, + { value: 'Audit Log', path: pb.auditLog() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -106,6 +108,9 @@ export default function SystemLayout() { IP Pools + + Audit Log + diff --git a/app/layouts/helpers.tsx b/app/layouts/helpers.tsx index ae08a2e69..3c36bb390 100644 --- a/app/layouts/helpers.tsx +++ b/app/layouts/helpers.tsx @@ -28,7 +28,7 @@ export function ContentPane() { >
-
+
diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx new file mode 100644 index 000000000..b754ea5e8 --- /dev/null +++ b/app/pages/system/AuditLog.tsx @@ -0,0 +1,427 @@ +/* + * 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 { useVirtualizer } from '@tanstack/react-virtual' +import cn from 'classnames' +import { differenceInMilliseconds } from 'date-fns' +import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { match, P } from 'ts-pattern' +import { type JsonValue } from 'type-fest' + +import { api, AuditLogListQueryParams } from '@oxide/api' +import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' +import { useIntervalPicker } from '~/components/RefetchIntervalPicker' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { Badge } from '~/ui/lib/Badge' +import { Button } from '~/ui/lib/Button' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { Spinner } from '~/ui/lib/Spinner' +import { Truncate } from '~/ui/lib/Truncate' +import { classed } from '~/util/classed' +import { toSyslogDateString, toSyslogTimeString } from '~/util/date' +import { docLinks } from '~/util/links' + +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 Primitive = ({ value }: { value: null | boolean | number | string | Date }) => ( + + {value === null + ? 'null' + : typeof value === 'string' + ? `"${value}"` + : value instanceof Date + ? `"${value.toISOString()}"` + : String(value)} + +) + +// 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'} + + ))} + + {'}'} + + ) +}) + +// todo +// might want to still render the items in case of error +const ErrorState = () => { + return
Error State
+} + +// todo +const LoadingState = () => { + return
Loading State
+} + +function StatusCodeCell({ code }: { code: number }) { + const color = + code >= 200 && code < 400 + ? 'default' + : code >= 400 && code < 500 + ? 'notice' + : 'destructive' + return {code} +} + +const colWidths = { + gridTemplateColumns: '7.5rem 3rem 180px 140px 120px 140px 300px 300px', +} + +const HeaderCell = classed.div`text-mono-sm text-tertiary` + +const EXPANDED_HEIGHT = 288 // h-72 * 4 + +export default function SiloAuditLogsPage() { + const [expandedItem, setExpandedItem] = useState(null) + + // 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, + limit: 500, + sortBy: 'time_and_id_descending', + } + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isPending, + isFetching, + error, + } = useInfiniteQuery({ + queryKey: ['auditLogList', { query: queryParams }], + queryFn: ({ pageParam }) => + api.methods + .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, + }) + + const allItems = useMemo(() => { + return data?.pages.flatMap((page) => page.items) || [] + }, [data]) + + const parentRef = useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: allItems.length, + getScrollElement: () => document.querySelector('#scroll-container'), + estimateSize: useCallback( + (index) => { + return expandedItem === index.toString() ? 36 + EXPANDED_HEIGHT : 36 + }, + [expandedItem] + ), + overscan: 20, + }) + + const handleToggle = useCallback( + (index: string | null) => { + setExpandedItem(index) + rowVirtualizer.measure() + }, + [rowVirtualizer] + ) + + const logTable = ( + <> +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const log = allItems[virtualRow.index] + const isExpanded = expandedItem === virtualRow.index.toString() + // only bother doing all this computation if we're the expanded row + const json = isExpanded ? camelToSnakeJson(log) : undefined + + 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: 'unauthenticated' }, () => [undefined, undefined]) + .exhaustive() + + return ( +
+
{ + const newValue = isExpanded ? null : virtualRow.index.toString() + handleToggle(newValue) + }} + // a11y thing: make it focusable and let the user press enter on it to toggle + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + const newValue = isExpanded ? null : virtualRow.index.toString() + handleToggle(newValue) + } + }} + role="button" // oxlint-disable-line prefer-tag-over-role + tabIndex={0} + > + {/* 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 +
+
+ {isExpanded && ( +
+
+                    
+                  
+
+ )} +
+ ) + })} +
+
+ {!hasNextPage && !isFetching && !isPending && allItems.length > 0 ? ( +
+ No more logs to show within selected timeline +
+ ) : ( + + )} +
+ + ) + + 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}
+
+ +
+ Time Completed + Status + Operation + Actor ID + Auth Method + Silo ID + Duration +
+ +
+
+ {error ? : !isLoading ? logTable : } +
+
+ + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index f653b18af..0b4c2a7b8 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -209,6 +209,10 @@ export const routes = createRoutesFromElements( /> + import('./pages/system/AuditLog').then(convert)} + /> redirect(pb.projects())} element={null} /> diff --git a/app/ui/lib/DatePicker.tsx b/app/ui/lib/DatePicker.tsx index ae50282ff..e4ab8ac3a 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', - 'relative flex h-11 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise focus:z-10', + 'relative flex h-10 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise 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 ff7e2c71c..0f696e30d 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', - 'relative flex h-11 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise focus:z-10', + 'relative flex h-10 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise 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 71aef4a94..b6be2bc60 100644 --- a/app/ui/lib/Listbox.tsx +++ b/app/ui/lib/Listbox.tsx @@ -101,7 +101,7 @@ export const Listbox = ({ id={id} name={name} className={cn( - `flex h-11 items-center justify-between rounded border text-sans-md`, + `flex h-10 items-center justify-between rounded border text-sans-md`, hasError ? 'focus-error border-error-secondary hover:border-error' : 'border-default hover:border-hover', diff --git a/app/ui/lib/Table.tsx b/app/ui/lib/Table.tsx index b48d13771..27e3a0465 100644 --- a/app/ui/lib/Table.tsx +++ b/app/ui/lib/Table.tsx @@ -105,7 +105,7 @@ Table.Cell = ({ height = 'small', className, children, ...props }: TableCellProp
{children} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 2393aa324..d1703a680 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)": [], "diskInventory (/system/inventory/disks)": [ { 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 913f9c6f1..e2fb02ea8 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -12,6 +12,7 @@ export const links = { accessDocs: 'https://docs.oxide.computer/guides/configuring-access', affinityDocs: 'https://docs.oxide.computer/guides/deploying-workloads#_affinity_and_anti_affinity', + auditLogDocs: 'https://docs.oxide.computer/guides/audit-logs', cloudInitFormat: 'https://cloudinit.readthedocs.io/en/latest/explanation/format.html', cloudInitExamples: 'https://cloudinit.readthedocs.io/en/latest/reference/examples.html', deviceTokenSetup: @@ -75,6 +76,10 @@ export const docLinks = { href: links.affinityDocs, linkText: 'Anti-Affinity Groups', }, + auditLog: { + href: links.auditLogDocs, + linkText: 'Audit Logs', + }, deviceTokens: { href: links.deviceTokenSetup, linkText: 'Access Tokens', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 202994c02..c1c4fa264 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -46,6 +46,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", "diskInventory": "/system/inventory/disks", "disks": "/projects/p/disks", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 1a75b7354..2217b54cb 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -128,6 +128,8 @@ export const pb = { samlIdp: (params: PP.IdentityProvider) => `${pb.silo(params)}/idps/saml/${params.provider}`, + 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..9e5aa9265 --- /dev/null +++ b/mock-api/audit-log.ts @@ -0,0 +1,207 @@ +/* + * 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 = ['session_cookie', 'api_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: 'api_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 ed6851294..a2593fb11 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 './floating-ip' diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 4b16b60b7..630e83e96 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -483,6 +483,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 6803c75b9..e5dc831c5 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1790,7 +1790,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) + }, // Misc endpoints we're not using yet in the console affinityGroupCreate: NotImplemented, affinityGroupDelete: NotImplemented, @@ -1808,7 +1824,6 @@ export const handlers = makeHandlers({ alertReceiverSubscriptionRemove: NotImplemented, alertReceiverView: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, - auditLogList: NotImplemented, certificateCreate: NotImplemented, certificateDelete: NotImplemented, certificateList: NotImplemented,