diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 2b4a2da68..ce6ac7f60 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' @@ -53,6 +54,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) @@ -96,6 +98,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..c0700366a --- /dev/null +++ b/app/pages/system/AuditLog.tsx @@ -0,0 +1,666 @@ +/* + * 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, useEffect, 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 { 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 { 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 { Spinner } from '~/ui/lib/Spinner' +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 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'} + + ))} + + {'}'} + + ) +}) + +const ErrorState = ({ error, onDismiss }: { error: string; onDismiss: () => void }) => { + return ( +
+
+ + {error} +
+ +
+ ) +} + +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 colWidths = { + gridTemplateColumns: '7.75rem 3rem 160px 130px 120px 130px 1fr', +} + +const HeaderCell = classed.div`text-mono-sm text-tertiary` + +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.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, + }) + + // 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) + + const rowVirtualizer = useVirtualizer({ + count: allItems.length, + getScrollElement: () => document.querySelector('#scroll-container'), + estimateSize: () => 36, + overscan: 40, + }) + + const handleToggle = useCallback( + (index: string | null) => { + setExpandedItem(index) + setTimeout(() => { + rowVirtualizer.measure() + }, 0) + }, + [rowVirtualizer] + ) + + const logTable = ( + <> +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const log = allItems[virtualRow.index] + const indexStr = virtualRow.index.toString() + const isExpanded = expandedItem === indexStr + + 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 ( +
+
{ + handleToggle(indexStr) + }} + // 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 === ' ') { + handleToggle(indexStr) + } + }} + 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 +
+
+
+ ) + })} +
+
+ {!hasNextPage && !isFetching && !isPending && allItems.length > 0 ? ( +
+ No more logs to show within selected timeline +
+ ) : ( + + )} +
+ + ) + + const selectedItem = expandedItem ? allItems[parseInt(expandedItem, 10)] : null + + const errorMessage = error?.message ?? 'An error occurred while loading audit logs' + const showError = error && !dismissedError + + 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 +
+ {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: 'unauthenticated' }, () => [undefined, undefined]) + .exhaustive() + + const currentIndex = parseInt(expandedItem!, 10) + + return ( + handleToggle(index.toString())} + onClose={() => 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 +}) => { + const snakeJson = camelToSnakeJson(item) + const json = JSON.stringify(snakeJson, null, 2) + + return ( +
+
+
+ + +

+ {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 + +
+
+ + + +
+
+

Raw JSON

+ +
+
+
+            
+          
+
+
+
+ ) +} 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/math.ts b/app/util/math.ts index 6487313e5..3b730f5bb 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -104,3 +104,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 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 7aaf91adc..ee4eea637 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1807,7 +1807,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, @@ -1825,7 +1841,6 @@ export const handlers = makeHandlers({ alertReceiverSubscriptionRemove: NotImplemented, alertReceiverView: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, - auditLogList: NotImplemented, certificateCreate: NotImplemented, certificateDelete: NotImplemented, certificateList: NotImplemented, diff --git a/tailwind.config.ts b/tailwind.config.ts index 967c8f030..dd2768389 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -69,10 +69,13 @@ export default { pulse2: 'pulse2 1.3s cubic-bezier(.4,0,.6,1) infinite', }, keyframes: { + pulse: { + '50%': { opacity: '0.66' }, + }, // different from pulse in that we go up a little before we go back down. // pulse starts at opacity 1 pulse2: { - '0%, 100%': { opacity: '0.75' }, + '0%, 100%': { opacity: '0.66' }, '50%': { opacity: '1' }, }, },