From 0c49f074a5a9cdd3ce7360ba3392a0c674ef67a8 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:05:53 +0100 Subject: [PATCH 01/75] Use WIP audit log Omicron API --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 232 +++++++++++++++++++++++--- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 38 +++++ app/api/__generated__/validate.ts | 155 ++++++++++++++++- 5 files changed, 398 insertions(+), 31 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index f18fa05d63..af4b8a89cb 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -99ffcbe2b1f4bddc4be85e45d9d1a0d920e2201b +ef64ac31dc2c564a5bd40b1fb78cff269c2f9d1a diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index ba3609b9bb..1cc95a7c16 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -55,7 +55,7 @@ export type Address = { export type AddressConfig = { /** The set of addresses assigned to the port configuration. */ addresses: Address[] - /** Link to assign the address to */ + /** Link to assign the addresses to. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name } @@ -613,6 +613,18 @@ export type AntiAffinityGroupResultsPage = { */ export type AntiAffinityGroupUpdate = { description?: string | null; name?: Name | null } +/** + * An identifier for an artifact. + */ +export type ArtifactId = { + /** The kind of artifact this is. */ + kind: string + /** The artifact's name. */ + name: string + /** The artifact's version. */ + version: string +} + /** * Authorization scope for a timeseries. * @@ -837,7 +849,7 @@ export type BgpPeer = { /** How long to hold a peer in idle before attempting a new session (seconds). */ idleHoldTime: number /** The name of interface to peer on. This is relative to the port configuration this BGP peer configuration is a part of. For example this value could be phy0 to refer to a primary physical interface. Or it could be vlan47 to refer to a VLAN interface. */ - interfaceName: string + interfaceName: Name /** How often to send keepalive requests (seconds). */ keepalive: number /** Apply a local preference to routes received from this peer. */ @@ -855,7 +867,7 @@ export type BgpPeer = { } export type BgpPeerConfig = { - /** Link that the peer is reachable on */ + /** Link that the peer is reachable on. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name peers: BgpPeer[] } @@ -1646,6 +1658,7 @@ export type DeviceAccessToken = { /** A unique, immutable, system-controlled identifier for the token. Note that this ID is not the bearer token itself, which starts with "oxide-token-" */ id: string timeCreated: Date + /** Expiration timestamp. A null value means the token does not automatically expire. */ timeExpires?: Date | null } @@ -2259,7 +2272,7 @@ Currently, the global default auto-restart policy is "best-effort", so instances This disk can either be attached if it already exists or created along with the instance. -Specifying a boot disk is optional but recommended to ensure predictable boot behavior. The boot disk can be set during instance creation or later if the instance is stopped. +Specifying a boot disk is optional but recommended to ensure predictable boot behavior. The boot disk can be set during instance creation or later if the instance is stopped. The boot disk counts against the disk attachment limit. An instance that does not have a boot disk set will use the boot options specified in its UEFI settings, which are controlled by both the instance's UEFI firmware and the guest operating system. Boot options can change as disks are attached and detached, which may result in an instance that only boots to the EFI shell until a boot disk is set. */ bootDisk?: InstanceDiskAttachment | null @@ -2268,7 +2281,7 @@ An instance that does not have a boot disk set will use the boot options specifi Disk attachments of type "create" will be created, while those of type "attach" must already exist. -The order of this list does not guarantee a boot order for the instance. Use the boot_disk attribute to specify a boot disk. */ +The order of this list does not guarantee a boot order for the instance. Use the boot_disk attribute to specify a boot disk. When boot_disk is specified it will count against the disk attachment limit. */ disks?: InstanceDiskAttachment[] /** The external IP addresses provided to this instance. @@ -2726,11 +2739,11 @@ export type TxEqConfig = { * Switch link configuration. */ export type LinkConfigCreate = { - /** Whether or not to set autonegotiation */ + /** Whether or not to set autonegotiation. */ autoneg: boolean /** The requested forward-error correction method. If this is not specified, the standard FEC for the underlying media will be applied if it can be determined. */ fec?: LinkFec | null - /** Link name */ + /** Link name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name /** The link-layer discovery protocol (LLDP) configuration for the link. */ lldp: LldpLinkConfigCreate @@ -2738,7 +2751,7 @@ export type LinkConfigCreate = { mtu: number /** The speed of the link. */ speed: LinkSpeed - /** Optional tx_eq settings */ + /** Optional tx_eq settings. */ txEq?: TxEqConfig | null } @@ -3213,7 +3226,7 @@ export type Route = { * Route configuration data associated with a switch port configuration. */ export type RouteConfig = { - /** Link the route should be active on */ + /** Link name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name /** The set of routes assigned to a switch port. */ routes: Route[] @@ -3644,7 +3657,7 @@ An expunged sled is always non-provisionable. */ | { kind: 'expunged' } /** - * The current state of the sled, as determined by Nexus. + * The current state of the sled. */ export type SledState = /** The sled is currently active, and has resources allocated on it. */ @@ -3666,7 +3679,7 @@ export type Sled = { policy: SledPolicy /** The rack to which this Sled is currently attached */ rackId: string - /** The current state Nexus believes the sled to be in. */ + /** The current state of the sled. */ state: SledState /** timestamp when this resource was created */ timeCreated: Date @@ -3899,7 +3912,7 @@ export type SwitchInterfaceConfig = { /** A unique identifier for this switch interface. */ id: string /** The name of this switch interface. */ - interfaceName: string + interfaceName: Name /** The switch interface kind. */ kind: SwitchInterfaceKind2 /** The port settings object this switch interface configuration belongs to. */ @@ -3929,7 +3942,7 @@ export type SwitchInterfaceKind = export type SwitchInterfaceConfigCreate = { /** What kind of switch interface this configuration represents. */ kind: SwitchInterfaceKind - /** Link the interface will be assigned to */ + /** Link name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ linkName: Name /** Whether or not IPv6 is enabled. */ v6Enabled: boolean @@ -3944,7 +3957,7 @@ export type SwitchPort = { /** The id of the switch port. */ id: string /** The name of this switch port. */ - portName: string + portName: Name /** The primary settings group of this switch port. Will be `None` until this switch port is configured. */ portSettingsId?: string | null /** The rack this switch port belongs to. */ @@ -3966,7 +3979,7 @@ export type SwitchPortAddressView = { /** The name of the address lot this address is drawn from. */ addressLotName: Name /** The interface name this address belongs to. */ - interfaceName: string + interfaceName: Name /** The port settings object this address configuration belongs to. */ portSettingsId: string /** An optional VLAN ID */ @@ -4050,7 +4063,7 @@ export type SwitchPortLinkConfig = { /** The requested forward-error correction method. If this is not specified, the standard FEC for the underlying media will be applied if it can be determined. */ fec?: LinkFec | null /** The name of this link. */ - linkName: string + linkName: Name /** The link-layer discovery protocol service configuration for this link. */ lldpLinkConfig?: LldpLinkConfig | null /** The maximum transmission unit for this link. */ @@ -4082,7 +4095,7 @@ export type SwitchPortRouteConfig = { /** The route's gateway address. */ gw: string /** The interface name this route configuration is assigned to. */ - interfaceName: string + interfaceName: Name /** The port settings object this route configuration belongs to. */ portSettingsId: string /** RIB Priority indicating priority within and across protocols. */ @@ -4147,19 +4160,19 @@ export type SwitchPortSettings = { * Parameters for creating switch port settings. Switch port settings are the central data structure for setting up external networking. Switch port settings include link, interface, route, address and dynamic network protocol configuration. */ export type SwitchPortSettingsCreate = { - /** Addresses indexed by interface name. */ + /** Address configurations. */ addresses: AddressConfig[] - /** BGP peers indexed by interface name. */ + /** BGP peer configurations. */ bgpPeers?: BgpPeerConfig[] description: string groups?: NameOrId[] - /** Interfaces indexed by link name. */ + /** Interface configurations. */ interfaces?: SwitchInterfaceConfigCreate[] - /** Links indexed by phy name. On ports that are not broken out, this is always phy0. On a 2x breakout the options are phy0 and phy1, on 4x phy0-phy3, etc. */ + /** Link configurations. */ links: LinkConfigCreate[] name: Name portConfig: SwitchPortConfigCreate - /** Routes indexed by interface name. */ + /** Route configurations. */ routes?: RouteConfig[] } @@ -4285,6 +4298,82 @@ export type TimeseriesSchemaResultsPage = { nextPage?: string | null } +/** + * Metadata about an individual TUF artifact. + * + * Found within a `TufRepoDescription`. + */ +export type TufArtifactMeta = { + /** The hash of the artifact. */ + hash: string + /** The artifact ID. */ + id: ArtifactId + /** The size of the artifact in bytes. */ + size: number +} + +/** + * Metadata about a TUF repository. + * + * Found within a `TufRepoDescription`. + */ +export type TufRepoMeta = { + /** The file name of the repository. + +This is purely used for debugging and may not always be correct (e.g. with wicket, we read the file contents from stdin so we don't know the correct file name). */ + fileName: string + /** The hash of the repository. + +This is a slight abuse of `ArtifactHash`, since that's the hash of individual artifacts within the repository. However, we use it here for convenience. */ + hash: string + /** The system version in artifacts.json. */ + systemVersion: string + /** The version of the targets role. */ + targetsRoleVersion: number + /** The time until which the repo is valid. */ + validUntil: Date +} + +/** + * A description of an uploaded TUF repository. + */ +export type TufRepoDescription = { + /** Information about the artifacts present in the repository. */ + artifacts: TufArtifactMeta[] + /** Information about the repository. */ + repo: TufRepoMeta +} + +/** + * Data about a successful TUF repo get from Nexus. + */ +export type TufRepoGetResponse = { + /** The description of the repository. */ + description: TufRepoDescription +} + +/** + * Status of a TUF repo import. + * + * Part of `TufRepoInsertResponse`. + */ +export type TufRepoInsertStatus = + /** The repository already existed in the database. */ + | 'already_exists' + + /** The repository did not exist, and was inserted into the database. */ + | 'inserted' + +/** + * Data about a successful TUF repo import into Nexus. + */ +export type TufRepoInsertResponse = { + /** The repository as present in the database. */ + recorded: TufRepoDescription + /** Whether this repository already existed or is new. */ + status: TufRepoInsertStatus +} + /** * A sled that has not been added to an initialized rack yet */ @@ -4776,6 +4865,48 @@ export type SystemMetricName = | 'cpus_provisioned' | 'ram_provisioned' +/** + * Audit log entry + */ +export type AuditLogEntry = { + /** API token or session cookie. Optional because it will not be defined on unauthenticated requests like login attempts. */ + accessMethod?: string | null + /** User ID of the actor who performed the action */ + actorId?: string | null + actorSiloId?: string | null + /** Error information if the action failed */ + errorCode?: string | null + errorMessage?: string | null + /** HTTP status code */ + httpStatusCode: number + /** Unique identifier for the audit log entry */ + id: string + /** API endpoint ID, e.g., `project_create` */ + operationId: string + /** Request ID for tracing requests through the system */ + requestId: string + /** Full URL of the request */ + requestUri: string + /** Resource identifier */ + resourceId?: string | null + /** IP address that made the request */ + sourceIp: string + /** Time operation completed */ + timeCompleted: Date + /** When the request was received */ + timestamp: Date +} + +/** + * A single page of results + */ +export type AuditLogEntryResultsPage = { + /** list of items on this page of results */ + items: AuditLogEntry[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + /** * Supported set of sort modes for scanning by name only * @@ -6036,6 +6167,14 @@ export interface SystemTimeseriesSchemaListQueryParams { pageToken?: string | null } +export interface SystemUpdatePutRepositoryQueryParams { + fileName: string +} + +export interface SystemUpdateGetRepositoryPathParams { + systemVersion: string +} + export interface SiloUserListQueryParams { limit?: number | null pageToken?: string | null @@ -6280,6 +6419,14 @@ export interface WebhookSecretsDeletePathParams { secretId: string } +export interface AuditLogListQueryParams { + endTime?: Date | null + limit?: number | null + pageToken?: string | null + sortBy?: TimeAndIdSortMode + startTime?: Date +} + type EmptyObj = Record export class Api extends HttpClient { methods = { @@ -9650,6 +9797,33 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Upload TUF repository + */ + systemUpdatePutRepository: ( + { query }: { query: SystemUpdatePutRepositoryQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/update/repository`, + method: 'PUT', + query, + ...params, + }) + }, + /** + * Fetch TUF repository description + */ + systemUpdateGetRepository: ( + { path }: { path: SystemUpdateGetRepositoryPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/update/repository/${path.systemVersion}`, + method: 'GET', + ...params, + }) + }, /** * Get the current target release of the rack's system software */ @@ -10251,6 +10425,20 @@ export class Api extends HttpClient { ...params, }) }, + /** + * View audit log + */ + auditLogList: ( + { query = {} }: { query?: AuditLogListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/audit-log`, + method: 'GET', + query, + ...params, + }) + }, } ws = { /** diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 907cbd5f88..8f57249044 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -99ffcbe2b1f4bddc4be85e45d9d1a0d920e2201b +ef64ac31dc2c564a5bd40b1fb78cff269c2f9d1a diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 096491ef6e..7e68413141 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -1481,6 +1481,18 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `PUT /v1/system/update/repository` */ + systemUpdatePutRepository: (params: { + query: Api.SystemUpdatePutRepositoryQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/system/update/repository/:systemVersion` */ + systemUpdateGetRepository: (params: { + path: Api.SystemUpdateGetRepositoryPathParams + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/system/update/target-release` */ targetReleaseView: (params: { req: Request @@ -1739,6 +1751,12 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /v1/system/audit-log` */ + auditLogList: (params: { + query: Api.AuditLogListQueryParams + req: Request + cookies: Record + }) => Promisable> } function validateParams( @@ -3034,6 +3052,22 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { null ) ), + http.put( + '/v1/system/update/repository', + handler( + handlers['systemUpdatePutRepository'], + schema.SystemUpdatePutRepositoryParams, + null + ) + ), + http.get( + '/v1/system/update/repository/:systemVersion', + handler( + handlers['systemUpdateGetRepository'], + schema.SystemUpdateGetRepositoryParams, + null + ) + ), http.get( '/v1/system/update/target-release', handler(handlers['targetReleaseView'], null, null) @@ -3222,5 +3256,9 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/webhook-secrets/:secretId', handler(handlers['webhookSecretsDelete'], schema.WebhookSecretsDeleteParams, null) ), + http.get( + '/v1/system/audit-log', + handler(handlers['auditLogList'], schema.AuditLogListParams, null) + ), ] } diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 3c54af9f8e..9b7ba859b3 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -602,6 +602,14 @@ export const AntiAffinityGroupUpdate = z.preprocess( }) ) +/** + * An identifier for an artifact. + */ +export const ArtifactId = z.preprocess( + processResponseBody, + z.object({ kind: z.string(), name: z.string(), version: z.string() }) +) + /** * Authorization scope for a timeseries. * @@ -805,7 +813,7 @@ export const BgpPeer = z.preprocess( enforceFirstAs: SafeBoolean, holdTime: z.number().min(0).max(4294967295), idleHoldTime: z.number().min(0).max(4294967295), - interfaceName: z.string(), + interfaceName: Name, keepalive: z.number().min(0).max(4294967295), localPref: z.number().min(0).max(4294967295).nullable().optional(), md5AuthKey: z.string().nullable().optional(), @@ -3421,7 +3429,7 @@ export const SledPolicy = z.preprocess( ) /** - * The current state of the sled, as determined by Nexus. + * The current state of the sled. */ export const SledState = z.preprocess( processResponseBody, @@ -3632,7 +3640,7 @@ export const SwitchInterfaceConfig = z.preprocess( processResponseBody, z.object({ id: z.string().uuid(), - interfaceName: z.string(), + interfaceName: Name, kind: SwitchInterfaceKind2, portSettingsId: z.string().uuid(), v6Enabled: SafeBoolean, @@ -3668,7 +3676,7 @@ export const SwitchPort = z.preprocess( processResponseBody, z.object({ id: z.string().uuid(), - portName: z.string(), + portName: Name, portSettingsId: z.string().uuid().nullable().optional(), rackId: z.string().uuid(), switchLocation: z.string(), @@ -3685,7 +3693,7 @@ export const SwitchPortAddressView = z.preprocess( addressLotBlockId: z.string().uuid(), addressLotId: z.string().uuid(), addressLotName: Name, - interfaceName: z.string(), + interfaceName: Name, portSettingsId: z.string().uuid(), vlanId: z.number().min(0).max(65535).nullable().optional(), }) @@ -3753,7 +3761,7 @@ export const SwitchPortLinkConfig = z.preprocess( z.object({ autoneg: SafeBoolean, fec: LinkFec.nullable().optional(), - linkName: z.string(), + linkName: Name, lldpLinkConfig: LldpLinkConfig.nullable().optional(), mtu: z.number().min(0).max(65535), portSettingsId: z.string().uuid(), @@ -3778,7 +3786,7 @@ export const SwitchPortRouteConfig = z.preprocess( z.object({ dst: IpNet, gw: z.string().ip(), - interfaceName: z.string(), + interfaceName: Name, portSettingsId: z.string().uuid(), ribPriority: z.number().min(0).max(255).nullable().optional(), vlanId: z.number().min(0).max(65535).nullable().optional(), @@ -3980,6 +3988,70 @@ export const TimeseriesSchemaResultsPage = z.preprocess( z.object({ items: TimeseriesSchema.array(), nextPage: z.string().nullable().optional() }) ) +/** + * Metadata about an individual TUF artifact. + * + * Found within a `TufRepoDescription`. + */ +export const TufArtifactMeta = z.preprocess( + processResponseBody, + z.object({ hash: z.string(), id: ArtifactId, size: z.number().min(0) }) +) + +/** + * Metadata about a TUF repository. + * + * Found within a `TufRepoDescription`. + */ +export const TufRepoMeta = z.preprocess( + processResponseBody, + z.object({ + fileName: z.string(), + hash: z.string(), + systemVersion: z + .string() + .regex( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ + ), + targetsRoleVersion: z.number().min(0), + validUntil: z.coerce.date(), + }) +) + +/** + * A description of an uploaded TUF repository. + */ +export const TufRepoDescription = z.preprocess( + processResponseBody, + z.object({ artifacts: TufArtifactMeta.array(), repo: TufRepoMeta }) +) + +/** + * Data about a successful TUF repo get from Nexus. + */ +export const TufRepoGetResponse = z.preprocess( + processResponseBody, + z.object({ description: TufRepoDescription }) +) + +/** + * Status of a TUF repo import. + * + * Part of `TufRepoInsertResponse`. + */ +export const TufRepoInsertStatus = z.preprocess( + processResponseBody, + z.enum(['already_exists', 'inserted']) +) + +/** + * Data about a successful TUF repo import into Nexus. + */ +export const TufRepoInsertResponse = z.preprocess( + processResponseBody, + z.object({ recorded: TufRepoDescription, status: TufRepoInsertStatus }) +) + /** * A sled that has not been added to an initialized rack yet */ @@ -4461,6 +4533,37 @@ export const SystemMetricName = z.preprocess( z.enum(['virtual_disk_space_provisioned', 'cpus_provisioned', 'ram_provisioned']) ) +/** + * Audit log entry + */ +export const AuditLogEntry = z.preprocess( + processResponseBody, + z.object({ + accessMethod: z.string().nullable().optional(), + actorId: z.string().uuid().nullable().optional(), + actorSiloId: z.string().uuid().nullable().optional(), + errorCode: z.string().nullable().optional(), + errorMessage: z.string().nullable().optional(), + httpStatusCode: z.number().min(0).max(65535), + id: z.string().uuid(), + operationId: z.string(), + requestId: z.string(), + requestUri: z.string(), + resourceId: z.string().uuid().nullable().optional(), + sourceIp: z.string().ip(), + timeCompleted: z.coerce.date(), + timestamp: z.coerce.date(), + }) +) + +/** + * A single page of results + */ +export const AuditLogEntryResultsPage = z.preprocess( + processResponseBody, + z.object({ items: AuditLogEntry.array(), nextPage: z.string().nullable().optional() }) +) + /** * Supported set of sort modes for scanning by name only * @@ -6967,6 +7070,30 @@ export const SystemTimeseriesSchemaListParams = z.preprocess( }) ) +export const SystemUpdatePutRepositoryParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + fileName: z.string(), + }), + }) +) + +export const SystemUpdateGetRepositoryParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + systemVersion: z + .string() + .regex( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ + ), + }), + query: z.object({}), + }) +) + export const TargetReleaseViewParams = z.preprocess( processResponseBody, z.object({ @@ -7424,3 +7551,17 @@ export const WebhookSecretsDeleteParams = z.preprocess( query: z.object({}), }) ) + +export const AuditLogListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + endTime: z.coerce.date().nullable().optional(), + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: TimeAndIdSortMode.optional(), + startTime: z.coerce.date().optional(), + }), + }) +) From 91ea5ec38c2ed76579386b36ca064d8d991643d9 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:06:15 +0100 Subject: [PATCH 02/75] Stub out virtualised audit log page --- app/layouts/SiloLayout.tsx | 5 + app/layouts/helpers.tsx | 2 +- app/pages/SiloAuditLogsPage.tsx | 337 ++++++++++++++++++++++++++++++++ app/routes.tsx | 4 + app/util/date.ts | 16 ++ app/util/path-builder.ts | 1 + mock-api/audit-log.ts | 189 ++++++++++++++++++ mock-api/index.ts | 1 + mock-api/msw/db.ts | 1 + mock-api/msw/handlers.ts | 14 ++ 10 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 app/pages/SiloAuditLogsPage.tsx create mode 100644 mock-api/audit-log.ts diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 361727119a..d97687ff49 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -12,6 +12,7 @@ import { Access16Icon, Folder16Icon, Images16Icon, + Logs16Icon, Metrics16Icon, } from '@oxide/design-system/icons/react' @@ -37,6 +38,7 @@ export default function SiloLayout() { { value: 'Images', path: pb.siloImages() }, { value: 'Utilization', path: pb.siloUtilization() }, { value: 'Silo Access', path: pb.siloAccess() }, + { value: 'Audit Logs', path: pb.siloAuditLogs() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -70,6 +72,9 @@ export default function SiloLayout() { Silo Access + + Audit Logs + diff --git a/app/layouts/helpers.tsx b/app/layouts/helpers.tsx index ae08a2e69d..3c36bb3906 100644 --- a/app/layouts/helpers.tsx +++ b/app/layouts/helpers.tsx @@ -28,7 +28,7 @@ export function ContentPane() { >
-
+
diff --git a/app/pages/SiloAuditLogsPage.tsx b/app/pages/SiloAuditLogsPage.tsx new file mode 100644 index 0000000000..d9fdbe1dfe --- /dev/null +++ b/app/pages/SiloAuditLogsPage.tsx @@ -0,0 +1,337 @@ +/* + * 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 { api } 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 { 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 { toSyslogDateString, toSyslogTimeString } from '~/util/date' +import { docLinks } from '~/util/links' + +// silly faux highlighting +// avoids unnecessary import of a library and all that overhead +const HighlightJSON = memo(({ jsonString }: { jsonString: string }) => { + const Indent = ({ depth }: { depth: number }) => ( + + ) + + const Primitive = ({ value }: { value: null | boolean | number | string }) => ( + + {value === null ? 'null' : typeof value === 'string' ? `"${value}"` : String(value)} + + ) + + const renderValue = ( + value: null | boolean | number | string | object, + depth = 0 + ): React.ReactNode => { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + return + } + + if (Array.isArray(value)) { + if (value.length === 0) return [] + + return ( + <> + [ + {'\n'} + {value.map((item, index) => ( + + + {renderValue(item, depth + 1)} + {index < value.length - 1 && ,} + {'\n'} + + ))} + + ] + + ) + } + + if (typeof value === 'object') { + const entries = Object.entries(value) + if (entries.length === 0) return {'{}'} + + return ( + <> + {'{'} + {'\n'} + {entries.map(([key, val], index) => ( + + + {key} + : + {renderValue(val, depth + 1)} + {index < entries.length - 1 && ,} + {'\n'} + + ))} + + {'}'} + + ) + } + + return String(value) + } + + try { + const parsed = JSON.parse(jsonString) + return <>{renderValue(parsed)} + } catch { + return <>{jsonString} + } +}) + +export const handle = { crumb: 'Audit Logs' } + +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 = { + startTime, + endTime, + limit: 500, + } + + 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 auditLogs = useMemo(() => { + return data?.pages.flatMap((page) => page.items) || [] + }, [data]) + + const parentRef = useRef(null) + + const EXPANDED_HEIGHT = 282 + + const rowVirtualizer = useVirtualizer({ + count: auditLogs.length, + getScrollElement: () => document.querySelector('#scroll-container'), + estimateSize: useCallback( + (index) => { + return expandedItem === index.toString() ? 36 + EXPANDED_HEIGHT : 36 + }, + [expandedItem, EXPANDED_HEIGHT] + ), + overscan: 20, + }) + + const handleToggle = useCallback( + (index: string | null) => { + setExpandedItem(index) + rowVirtualizer.measure() + }, + [rowVirtualizer] + ) + + const LogTable = () => ( + <> +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const log = auditLogs[virtualRow.index] + const isExpanded = expandedItem === virtualRow.index.toString() + const jsonString = JSON.stringify(log, null, 2) + + return ( +
+
+ + {isExpanded && ( +
+
+                      
+                    
+
+ )} +
+
+ ) + })} +
+
+ {!hasNextPage && !isFetching && !isPending && auditLogs.length > 0 ? ( +
+ No more logs to show within selected timeline +
+ ) : ( + + )} +
+ + ) + + // todo + // might want to still render the items in case of error + const ErrorState = () => { + return
Error State
+ } + + // todo + const LoadingState = () => { + return
Loading State
+ } + + return ( + <> + + }>Audit Logs + } + summary="Audit logs provide a record of all system activities, including user actions, API calls, and system events." + links={[docLinks.auditLogs]} + /> + + +
+
{intervalPicker}
+
{dateTimeRangePicker}
+
+ +
+ {['Time', 'Status', 'Operation', 'Actor', 'Access Method', 'Silo', 'Duration'].map( + (header) => ( +
+ {header} +
+ ) + )} +
+ +
+
+ {error ? : !isLoading ? : } +
+
+ + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index f653b18af8..0ddb3672f4 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -252,6 +252,10 @@ export const routes = createRoutesFromElements( import('./pages/SiloAccessPage').then(convert)} /> + import('./pages/SiloAuditLogsPage').then(convert)} + /> {/* PROJECT */} diff --git a/app/util/date.ts b/app/util/date.ts index 9f504267df..81aa17e167 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/path-builder.ts b/app/util/path-builder.ts index 1a75b7354b..ca612806c6 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -107,6 +107,7 @@ export const pb = { siloAccess: () => '/access', siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, + siloAuditLogs: () => '/audit-logs', systemUtilization: () => '/system/utilization', diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts new file mode 100644 index 0000000000..46fc0f72bd --- /dev/null +++ b/mock-api/audit-log.ts @@ -0,0 +1,189 @@ +/* + * 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' + +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 mockAccessMethods = ['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): AuditLogEntry { + 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(), + accessMethod: mockAccessMethods[index % mockAccessMethods.length], + actorId: mockUserIds[index % mockUserIds.length], + actorSiloId: mockSiloIds[index % mockSiloIds.length], + errorCode: isError ? `E${statusCode}` : null, + errorMessage: isError ? `Operation failed with status ${statusCode}` : null, + httpStatusCode: statusCode, + operationId: operation, + requestId: mockRequestIds[index % mockRequestIds.length], + timestamp: baseTime, + timeCompleted: completedTime, + requestUri: `/v1/projects/default/${operation.replace('_', '/')}`, + resourceId: index % 3 === 0 ? uuid() : null, + sourceIp: mockSourceIps[index % mockSourceIps.length], + } +} + +export const auditLogs: AuditLogEntry[] = [ + // Recent successful operations + { + id: uuid(), + accessMethod: 'session_cookie', + actorId: mockUserIds[0], + actorSiloId: mockSiloIds[0], + errorCode: null, + errorMessage: null, + httpStatusCode: 201, + operationId: 'instance_create', + requestId: mockRequestIds[0], + timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 minutes ago + timeCompleted: new Date(Date.now() - 1000 * 60 * 5 + 321), // 1 second later + requestUri: '/v1/projects/admin-project/instances', + resourceId: uuid(), + sourceIp: '192.168.1.100', + }, + { + id: uuid(), + accessMethod: 'api_token', + actorId: mockUserIds[1], + actorSiloId: mockSiloIds[0], + errorCode: null, + errorMessage: null, + httpStatusCode: 200, + operationId: 'instance_start', + requestId: mockRequestIds[1], + timestamp: new Date(Date.now() - 1000 * 60 * 10), // 10 minutes ago + timeCompleted: new Date(Date.now() - 1000 * 60 * 10 + 126), // 1 second later + requestUri: '/v1/projects/admin-project/instances/web-server-prod/start', + resourceId: uuid(), + sourceIp: '10.0.0.50', + }, + // Failed operations + { + id: uuid(), + accessMethod: 'session_cookie', + actorId: mockUserIds[2], + actorSiloId: mockSiloIds[1], + errorCode: 'E403', + errorMessage: 'Insufficient permissions to delete instance', + httpStatusCode: 403, + operationId: 'instance_delete', + requestId: mockRequestIds[2], + timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 minutes ago + timeCompleted: new Date(Date.now() - 1000 * 60 * 15 + 147), // 1 second later + requestUri: '/v1/projects/dev-project/instances/test-instance', + resourceId: uuid(), + sourceIp: '172.16.0.25', + }, + { + id: uuid(), + accessMethod: null, + actorId: null, + actorSiloId: null, + errorCode: 'E401', + errorMessage: 'Authentication required', + httpStatusCode: 401, + operationId: 'user_login', + requestId: mockRequestIds[3], + timestamp: new Date(Date.now() - 1000 * 60 * 20), // 20 minutes ago + timeCompleted: new Date(Date.now() - 1000 * 60 * 20 + 16), // 1 second later + requestUri: '/v1/login', + resourceId: null, + sourceIp: '203.0.113.15', + }, + // More historical entries + { + id: uuid(), + accessMethod: 'session_cookie', + actorId: mockUserIds[0], + actorSiloId: mockSiloIds[0], + errorCode: null, + errorMessage: null, + httpStatusCode: 201, + operationId: 'project_create', + requestId: mockRequestIds[4], + timestamp: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago + timeCompleted: new Date(Date.now() - 1000 * 60 * 60 + 36), // 1 second later + requestUri: '/v1/projects', + resourceId: uuid(), + sourceIp: '192.168.1.100', + }, + // Generate additional entries + ...Array.from({ length: 199995 }, (_, i) => generateAuditLogEntry(i + 5)), +] diff --git a/mock-api/index.ts b/mock-api/index.ts index ed68512944..a2593fb117 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 cecc37e669..cfa07af05c 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -477,6 +477,7 @@ const initDb = { affinityGroupMemberLists: [...mock.affinityGroupMemberLists], antiAffinityGroups: [...mock.antiAffinityGroups], antiAffinityGroupMemberLists: [...mock.antiAffinityGroupMemberLists], + auditLogs: [...mock.auditLogs], deviceTokens: [...mock.deviceTokens], disks: [...mock.disks], diskBulkImportState: new Map(), diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c868aa41e6..32eeeabbf1 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1799,7 +1799,19 @@ export const handlers = makeHandlers({ ) return paginated(query, affinityGroups) }, + auditLogList: ({ query }) => { + let filteredLogs = db.auditLogs + if (query.startTime) { + filteredLogs = filteredLogs.filter((log) => log.timestamp >= query.startTime!) + } + + if (query.endTime) { + filteredLogs = filteredLogs.filter((log) => log.timestamp <= query.endTime!) + } + + return paginated(query, filteredLogs) + }, // Misc endpoints we're not using yet in the console affinityGroupCreate: NotImplemented, affinityGroupDelete: NotImplemented, @@ -1900,6 +1912,8 @@ export const handlers = makeHandlers({ systemPolicyUpdate: NotImplemented, systemQuotasList: NotImplemented, systemTimeseriesSchemaList: NotImplemented, + systemUpdateGetRepository: NotImplemented, + systemUpdatePutRepository: NotImplemented, targetReleaseUpdate: NotImplemented, targetReleaseView: NotImplemented, userBuiltinList: NotImplemented, From 5eb3ec0ffbaddf53b51cc41c213e6bc78b915b5e Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:06:22 +0100 Subject: [PATCH 03/75] Denser inputs --- app/ui/lib/DatePicker.tsx | 2 +- app/ui/lib/DateRangePicker.tsx | 2 +- app/ui/lib/Listbox.tsx | 2 +- app/ui/lib/Table.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/ui/lib/DatePicker.tsx b/app/ui/lib/DatePicker.tsx index ae50282ff2..e4ab8ac3a1 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 ff7e2c71c7..0f696e30d0 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 71aef4a94f..b6be2bc60a 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 b48d13771d..27e3a04653 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} From fdb45063ee02e215432aecc8647ba5808c16be47 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:08:05 +0100 Subject: [PATCH 04/75] Re-add missing link --- app/util/links.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/util/links.ts b/app/util/links.ts index 913f9c6f1a..29c2245eed 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', + auditLogsDocs: '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', }, + auditLogs: { + href: links.auditLogsDocs, + linkText: 'Audit Logs', + }, deviceTokens: { href: links.deviceTokenSetup, linkText: 'Access Tokens', From 35be627b1f88db6900fca03769ea2fdfb73f6241 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:10:28 +0100 Subject: [PATCH 05/75] Update API --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 174 ++++++++++++++++---------- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 39 ++++-- app/api/__generated__/validate.ts | 142 ++++++++++++++------- 5 files changed, 236 insertions(+), 123 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index af4b8a89cb..fee9b1e465 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -ef64ac31dc2c564a5bd40b1fb78cff269c2f9d1a +b610bb2bb8c75aa66aa9d0cec91da9a33b8703fb diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 1cc95a7c16..91762746ff 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -625,6 +625,48 @@ export type ArtifactId = { version: string } +/** + * Audit log entry + */ +export type AuditLogEntry = { + /** API token or session cookie. Optional because it will not be defined on unauthenticated requests like login attempts. */ + accessMethod?: string | null + /** User ID of the actor who performed the action */ + actorId?: string | null + actorSiloId?: string | null + /** Error information if the action failed */ + errorCode?: string | null + errorMessage?: string | null + /** HTTP status code */ + httpStatusCode: number + /** Unique identifier for the audit log entry */ + id: string + /** API endpoint ID, e.g., `project_create` */ + operationId: string + /** Request ID for tracing requests through the system */ + requestId: string + /** Full URL of the request */ + requestUri: string + /** Resource identifier */ + resourceId?: string | null + /** IP address that made the request */ + sourceIp: string + /** Time operation completed */ + timeCompleted: Date + /** When the request was received */ + timestamp: Date +} + +/** + * A single page of results + */ +export type AuditLogEntryResultsPage = { + /** list of items on this page of results */ + items: AuditLogEntry[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + /** * Authorization scope for a timeseries. * @@ -2053,6 +2095,13 @@ export type GroupResultsPage = { */ export type Hostname = string +/** + * A range of ICMP(v6) types or codes + * + * An inclusive-inclusive range of ICMP(v6) types or codes. The second value may be omitted to represent a single parameter. + */ +export type IcmpParamRange = string + export type IdentityProviderType = 'saml' /** @@ -3402,6 +3451,14 @@ export type SamlIdentityProviderCreate = { technicalContactEmail: string } +/** + * Configuration of inbound ICMP allowed by API services. + */ +export type ServiceIcmpConfig = { + /** When enabled, Nexus is able to receive ICMP Destination Unreachable type 3 (port unreachable) and type 4 (fragmentation needed), Redirect, and Time Exceeded messages. These enable Nexus to perform Path MTU discovery and better cope with fragmentation issues. Otherwise all inbound ICMP traffic will be dropped. */ + enabled: boolean +} + /** * Parameters for PUT requests to `/v1/system/update/target-release`. */ @@ -4521,6 +4578,8 @@ All IPv6 subnets created from this VPC must be taken from this range, which shou name: Name } +export type VpcFirewallIcmpFilter = { code?: IcmpParamRange | null; icmpType: number } + export type VpcFirewallRuleAction = 'allow' | 'deny' export type VpcFirewallRuleDirection = 'inbound' | 'outbound' @@ -4543,7 +4602,10 @@ export type VpcFirewallRuleHostFilter = /** * The protocols that may be specified in a firewall rule's filter */ -export type VpcFirewallRuleProtocol = 'TCP' | 'UDP' | 'ICMP' +export type VpcFirewallRuleProtocol = + | { type: 'tcp' } + | { type: 'udp' } + | { type: 'icmp'; value: VpcFirewallIcmpFilter | null } /** * Filters reduce the scope of a firewall rule. Without filters, the rule applies to all packets to the targets (or from the targets, if it's an outbound rule). With multiple filters, the rule applies only to packets matching ALL filters. The maximum number of each type of filter is 256. @@ -4865,48 +4927,6 @@ export type SystemMetricName = | 'cpus_provisioned' | 'ram_provisioned' -/** - * Audit log entry - */ -export type AuditLogEntry = { - /** API token or session cookie. Optional because it will not be defined on unauthenticated requests like login attempts. */ - accessMethod?: string | null - /** User ID of the actor who performed the action */ - actorId?: string | null - actorSiloId?: string | null - /** Error information if the action failed */ - errorCode?: string | null - errorMessage?: string | null - /** HTTP status code */ - httpStatusCode: number - /** Unique identifier for the audit log entry */ - id: string - /** API endpoint ID, e.g., `project_create` */ - operationId: string - /** Request ID for tracing requests through the system */ - requestId: string - /** Full URL of the request */ - requestUri: string - /** Resource identifier */ - resourceId?: string | null - /** IP address that made the request */ - sourceIp: string - /** Time operation completed */ - timeCompleted: Date - /** When the request was received */ - timestamp: Date -} - -/** - * A single page of results - */ -export type AuditLogEntryResultsPage = { - /** list of items on this page of results */ - items: AuditLogEntry[] - /** token used to fetch the next page of results (if any) */ - nextPage?: string | null -} - /** * Supported set of sort modes for scanning by name only * @@ -5774,6 +5794,14 @@ export interface SnapshotDeleteQueryParams { project?: NameOrId } +export interface AuditLogListQueryParams { + endTime?: Date | null + limit?: number | null + pageToken?: string | null + sortBy?: TimeAndIdSortMode + startTime?: Date +} + export interface PhysicalDiskListQueryParams { limit?: number | null pageToken?: string | null @@ -6419,14 +6447,6 @@ export interface WebhookSecretsDeletePathParams { secretId: string } -export interface AuditLogListQueryParams { - endTime?: Date | null - limit?: number | null - pageToken?: string | null - sortBy?: TimeAndIdSortMode - startTime?: Date -} - type EmptyObj = Record export class Api extends HttpClient { methods = { @@ -8568,6 +8588,20 @@ export class Api extends HttpClient { ...params, }) }, + /** + * View audit log + */ + auditLogList: ( + { query = {} }: { query?: AuditLogListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/audit-log`, + method: 'GET', + query, + ...params, + }) + }, /** * List physical disks */ @@ -9498,6 +9532,30 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Return whether API services can receive limited ICMP traffic + */ + networkingInboundIcmpView: (_: EmptyObj, params: FetchParams = {}) => { + return this.request({ + path: `/v1/system/networking/inbound-icmp`, + method: 'GET', + ...params, + }) + }, + /** + * Set whether API services can receive limited ICMP traffic + */ + networkingInboundIcmpUpdate: ( + { body }: { body: ServiceIcmpConfig }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/networking/inbound-icmp`, + method: 'PUT', + body, + ...params, + }) + }, /** * List loopback addresses */ @@ -10425,20 +10483,6 @@ export class Api extends HttpClient { ...params, }) }, - /** - * View audit log - */ - auditLogList: ( - { query = {} }: { query?: AuditLogListQueryParams }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/system/audit-log`, - method: 'GET', - query, - ...params, - }) - }, } ws = { /** diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 8f57249044..749d036c27 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -ef64ac31dc2c564a5bd40b1fb78cff269c2f9d1a +b610bb2bb8c75aa66aa9d0cec91da9a33b8703fb diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 7e68413141..8066a72df4 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -936,6 +936,12 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /v1/system/audit-log` */ + auditLogList: (params: { + query: Api.AuditLogListQueryParams + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/system/hardware/disks` */ physicalDiskList: (params: { query: Api.PhysicalDiskListQueryParams @@ -1341,6 +1347,17 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/system/networking/inbound-icmp` */ + networkingInboundIcmpView: (params: { + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/system/networking/inbound-icmp` */ + networkingInboundIcmpUpdate: (params: { + body: Json + req: Request + cookies: Record + }) => Promisable /** `GET /v1/system/networking/loopback-address` */ networkingLoopbackAddressList: (params: { query: Api.NetworkingLoopbackAddressListQueryParams @@ -1751,12 +1768,6 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable - /** `GET /v1/system/audit-log` */ - auditLogList: (params: { - query: Api.AuditLogListQueryParams - req: Request - cookies: Record - }) => Promisable> } function validateParams( @@ -2570,6 +2581,10 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/snapshots/:snapshot', handler(handlers['snapshotDelete'], schema.SnapshotDeleteParams, null) ), + http.get( + '/v1/system/audit-log', + handler(handlers['auditLogList'], schema.AuditLogListParams, null) + ), http.get( '/v1/system/hardware/disks', handler(handlers['physicalDiskList'], schema.PhysicalDiskListParams, null) @@ -2926,6 +2941,14 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/networking/bgp-status', handler(handlers['networkingBgpStatus'], null, null) ), + http.get( + '/v1/system/networking/inbound-icmp', + handler(handlers['networkingInboundIcmpView'], null, null) + ), + http.put( + '/v1/system/networking/inbound-icmp', + handler(handlers['networkingInboundIcmpUpdate'], null, schema.ServiceIcmpConfig) + ), http.get( '/v1/system/networking/loopback-address', handler( @@ -3256,9 +3279,5 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/webhook-secrets/:secretId', handler(handlers['webhookSecretsDelete'], schema.WebhookSecretsDeleteParams, null) ), - http.get( - '/v1/system/audit-log', - handler(handlers['auditLogList'], schema.AuditLogListParams, null) - ), ] } diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 9b7ba859b3..3a5ff5d723 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -610,6 +610,37 @@ export const ArtifactId = z.preprocess( z.object({ kind: z.string(), name: z.string(), version: z.string() }) ) +/** + * Audit log entry + */ +export const AuditLogEntry = z.preprocess( + processResponseBody, + z.object({ + accessMethod: z.string().nullable().optional(), + actorId: z.string().uuid().nullable().optional(), + actorSiloId: z.string().uuid().nullable().optional(), + errorCode: z.string().nullable().optional(), + errorMessage: z.string().nullable().optional(), + httpStatusCode: z.number().min(0).max(65535), + id: z.string().uuid(), + operationId: z.string(), + requestId: z.string(), + requestUri: z.string(), + resourceId: z.string().uuid().nullable().optional(), + sourceIp: z.string().ip(), + timeCompleted: z.coerce.date(), + timestamp: z.coerce.date(), + }) +) + +/** + * A single page of results + */ +export const AuditLogEntryResultsPage = z.preprocess( + processResponseBody, + z.object({ items: AuditLogEntry.array(), nextPage: z.string().nullable().optional() }) +) + /** * Authorization scope for a timeseries. * @@ -1952,6 +1983,20 @@ export const Hostname = z.preprocess( .regex(/^([a-zA-Z0-9]+[a-zA-Z0-9\-]*(? Date: Tue, 8 Jul 2025 11:40:59 +0100 Subject: [PATCH 06/75] Mock type fixes --- mock-api/audit-log.ts | 164 ++++++++++++++++++++------------------- mock-api/msw/handlers.ts | 8 +- 2 files changed, 89 insertions(+), 83 deletions(-) diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts index 46fc0f72bd..394943c95c 100644 --- a/mock-api/audit-log.ts +++ b/mock-api/audit-log.ts @@ -10,6 +10,8 @@ import { v4 as uuid } from 'uuid' import type { AuditLogEntry } from '@oxide/api' +import type { Json } from './json-type' + const mockUserIds = [ 'a47ac10b-58cc-4372-a567-0e02b2c3d479', '6ba7b810-9dad-11d1-80b4-00c04fd430c8', @@ -56,7 +58,7 @@ const mockOperations = [ 'ssh_key_delete', ] -const mockAccessMethods = ['session_cookie', 'api_token', null] +const mockAccessMethod = ['session_cookie', 'api_token', null] const mockHttpStatusCodes = [200, 201, 204, 400, 401, 403, 404, 409, 500, 502, 503] @@ -70,7 +72,7 @@ const mockSourceIps = [ const mockRequestIds = Array.from({ length: 20 }, () => uuid()) -function generateAuditLogEntry(index: number): AuditLogEntry { +function generateAuditLogEntry(index: number): Json { const operation = mockOperations[index % mockOperations.length] const statusCode = mockHttpStatusCodes[index % mockHttpStatusCodes.length] const isError = statusCode >= 400 @@ -84,105 +86,105 @@ function generateAuditLogEntry(index: number): AuditLogEntry { return { id: uuid(), - accessMethod: mockAccessMethods[index % mockAccessMethods.length], - actorId: mockUserIds[index % mockUserIds.length], - actorSiloId: mockSiloIds[index % mockSiloIds.length], - errorCode: isError ? `E${statusCode}` : null, - errorMessage: isError ? `Operation failed with status ${statusCode}` : null, - httpStatusCode: statusCode, - operationId: operation, - requestId: mockRequestIds[index % mockRequestIds.length], - timestamp: baseTime, - timeCompleted: completedTime, - requestUri: `/v1/projects/default/${operation.replace('_', '/')}`, - resourceId: index % 3 === 0 ? uuid() : null, - sourceIp: mockSourceIps[index % mockSourceIps.length], + access_method: mockAccessMethod[index % mockAccessMethod.length], + actor_id: mockUserIds[index % mockUserIds.length], + actor_silo_id: mockSiloIds[index % mockSiloIds.length], + error_code: isError ? `E${statusCode}` : null, + error_message: isError ? `Operation failed with status ${statusCode}` : null, + http_status_code: statusCode, + operation_id: operation, + request_id: mockRequestIds[index % mockRequestIds.length], + timestamp: baseTime.toISOString(), + time_completed: completedTime.toISOString(), + request_uri: `/v1/projects/default/${operation.replace('_', '/')}`, + resource_id: index % 3 === 0 ? uuid() : null, + source_ip: mockSourceIps[index % mockSourceIps.length], } } -export const auditLogs: AuditLogEntry[] = [ +export const auditLogs: Json = [ // Recent successful operations { id: uuid(), - accessMethod: 'session_cookie', - actorId: mockUserIds[0], - actorSiloId: mockSiloIds[0], - errorCode: null, - errorMessage: null, - httpStatusCode: 201, - operationId: 'instance_create', - requestId: mockRequestIds[0], - timestamp: new Date(Date.now() - 1000 * 60 * 5), // 5 minutes ago - timeCompleted: new Date(Date.now() - 1000 * 60 * 5 + 321), // 1 second later - requestUri: '/v1/projects/admin-project/instances', - resourceId: uuid(), - sourceIp: '192.168.1.100', + access_method: 'session_cookie', + actor_id: mockUserIds[0], + actor_silo_id: mockSiloIds[0], + error_code: null, + error_message: null, + http_status_code: 201, + operation_id: 'instance_create', + request_id: mockRequestIds[0], + timestamp: 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: '/v1/projects/admin-project/instances', + resource_id: uuid(), + source_ip: '192.168.1.100', }, { id: uuid(), - accessMethod: 'api_token', - actorId: mockUserIds[1], - actorSiloId: mockSiloIds[0], - errorCode: null, - errorMessage: null, - httpStatusCode: 200, - operationId: 'instance_start', - requestId: mockRequestIds[1], - timestamp: new Date(Date.now() - 1000 * 60 * 10), // 10 minutes ago - timeCompleted: new Date(Date.now() - 1000 * 60 * 10 + 126), // 1 second later - requestUri: '/v1/projects/admin-project/instances/web-server-prod/start', - resourceId: uuid(), - sourceIp: '10.0.0.50', + access_method: 'api_token', + actor_id: mockUserIds[1], + actor_silo_id: mockSiloIds[0], + error_code: null, + error_message: null, + http_status_code: 200, + operation_id: 'instance_start', + request_id: mockRequestIds[1], + timestamp: 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: '/v1/projects/admin-project/instances/web-server-prod/start', + resource_id: uuid(), + source_ip: '10.0.0.50', }, // Failed operations { id: uuid(), - accessMethod: 'session_cookie', - actorId: mockUserIds[2], - actorSiloId: mockSiloIds[1], - errorCode: 'E403', - errorMessage: 'Insufficient permissions to delete instance', - httpStatusCode: 403, - operationId: 'instance_delete', - requestId: mockRequestIds[2], - timestamp: new Date(Date.now() - 1000 * 60 * 15), // 15 minutes ago - timeCompleted: new Date(Date.now() - 1000 * 60 * 15 + 147), // 1 second later - requestUri: '/v1/projects/dev-project/instances/test-instance', - resourceId: uuid(), - sourceIp: '172.16.0.25', + access_method: 'session_cookie', + actor_id: mockUserIds[2], + actor_silo_id: mockSiloIds[1], + error_code: 'E403', + error_message: 'Insufficient permissions to delete instance', + http_status_code: 403, + operation_id: 'instance_delete', + request_id: mockRequestIds[2], + timestamp: 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: '/v1/projects/dev-project/instances/test-instance', + resource_id: uuid(), + source_ip: '172.16.0.25', }, { id: uuid(), - accessMethod: null, - actorId: null, - actorSiloId: null, - errorCode: 'E401', - errorMessage: 'Authentication required', - httpStatusCode: 401, - operationId: 'user_login', - requestId: mockRequestIds[3], - timestamp: new Date(Date.now() - 1000 * 60 * 20), // 20 minutes ago - timeCompleted: new Date(Date.now() - 1000 * 60 * 20 + 16), // 1 second later - requestUri: '/v1/login', - resourceId: null, - sourceIp: '203.0.113.15', + access_method: null, + actor_id: null, + actor_silo_id: null, + error_code: 'E401', + error_message: 'Authentication required', + http_status_code: 401, + operation_id: 'user_login', + request_id: mockRequestIds[3], + timestamp: 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: '/v1/login', + resource_id: null, + source_ip: '203.0.113.15', }, // More historical entries { id: uuid(), - accessMethod: 'session_cookie', - actorId: mockUserIds[0], - actorSiloId: mockSiloIds[0], - errorCode: null, - errorMessage: null, - httpStatusCode: 201, - operationId: 'project_create', - requestId: mockRequestIds[4], - timestamp: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago - timeCompleted: new Date(Date.now() - 1000 * 60 * 60 + 36), // 1 second later - requestUri: '/v1/projects', - resourceId: uuid(), - sourceIp: '192.168.1.100', + access_method: 'session_cookie', + actor_id: mockUserIds[0], + actor_silo_id: mockSiloIds[0], + error_code: null, + error_message: null, + http_status_code: 201, + operation_id: 'project_create', + request_id: mockRequestIds[4], + timestamp: 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: '/v1/projects', + resource_id: uuid(), + source_ip: '192.168.1.100', }, // Generate additional entries ...Array.from({ length: 199995 }, (_, i) => generateAuditLogEntry(i + 5)), diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 32eeeabbf1..42ab43d8e8 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1803,11 +1803,13 @@ export const handlers = makeHandlers({ let filteredLogs = db.auditLogs if (query.startTime) { - filteredLogs = filteredLogs.filter((log) => log.timestamp >= query.startTime!) + filteredLogs = filteredLogs.filter( + (log) => new Date(log.timestamp) >= query.startTime! + ) } if (query.endTime) { - filteredLogs = filteredLogs.filter((log) => log.timestamp <= query.endTime!) + filteredLogs = filteredLogs.filter((log) => new Date(log.timestamp) <= query.endTime!) } return paginated(query, filteredLogs) @@ -1873,6 +1875,8 @@ export const handlers = makeHandlers({ networkingLoopbackAddressCreate: NotImplemented, networkingLoopbackAddressDelete: NotImplemented, networkingLoopbackAddressList: NotImplemented, + networkingInboundIcmpView: NotImplemented, + networkingInboundIcmpUpdate: NotImplemented, networkingSwitchPortApplySettings: NotImplemented, networkingSwitchPortClearSettings: NotImplemented, networkingSwitchPortList: NotImplemented, From accfe37903e20efe4eb47b94ec9c36273011945a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 22 Jul 2025 22:38:16 -0500 Subject: [PATCH 07/75] move audit log to a system page --- app/layouts/SiloLayout.tsx | 5 ----- app/layouts/SystemLayout.tsx | 5 +++++ app/pages/SiloAuditLogsPage.tsx | 8 ++++---- app/routes.tsx | 8 ++++---- app/util/path-builder.ts | 3 ++- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index d97687ff49..361727119a 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -12,7 +12,6 @@ import { Access16Icon, Folder16Icon, Images16Icon, - Logs16Icon, Metrics16Icon, } from '@oxide/design-system/icons/react' @@ -38,7 +37,6 @@ export default function SiloLayout() { { value: 'Images', path: pb.siloImages() }, { value: 'Utilization', path: pb.siloUtilization() }, { value: 'Silo Access', path: pb.siloAccess() }, - { value: 'Audit Logs', path: pb.siloAuditLogs() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -72,9 +70,6 @@ export default function SiloLayout() { Silo Access - - Audit Logs - diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 69ff5828e5..9f15dc3322 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/pages/SiloAuditLogsPage.tsx b/app/pages/SiloAuditLogsPage.tsx index 8c3db297d5..1414c31a4b 100644 --- a/app/pages/SiloAuditLogsPage.tsx +++ b/app/pages/SiloAuditLogsPage.tsx @@ -107,7 +107,7 @@ const HighlightJSON = memo(({ jsonString }: { jsonString: string }) => { } }) -export const handle = { crumb: 'Audit Logs' } +export const handle = { crumb: 'Audit Log' } export default function SiloAuditLogsPage() { const [expandedItem, setExpandedItem] = useState(null) @@ -298,11 +298,11 @@ export default function SiloAuditLogsPage() { return ( <> - }>Audit Logs + }>Audit Log } - summary="Audit logs provide a record of all system activities, including user actions, API calls, and system events." + summary="The audit log provides a record of system activities, including user actions, API calls, and system events." links={[docLinks.auditLogs]} /> diff --git a/app/routes.tsx b/app/routes.tsx index 0ddb3672f4..7e371f3e72 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -209,6 +209,10 @@ export const routes = createRoutesFromElements( /> + import('./pages/SiloAuditLogsPage').then(convert)} + /> redirect(pb.projects())} element={null} /> @@ -252,10 +256,6 @@ export const routes = createRoutesFromElements( import('./pages/SiloAccessPage').then(convert)} /> - import('./pages/SiloAuditLogsPage').then(convert)} - /> {/* PROJECT */} diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index ca612806c6..2217b54cb9 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -107,7 +107,6 @@ export const pb = { siloAccess: () => '/access', siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, - siloAuditLogs: () => '/audit-logs', systemUtilization: () => '/system/utilization', @@ -129,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', From 34b743727a7e401e008c303122821bf73fcae82a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 23 Jul 2025 12:14:27 -0500 Subject: [PATCH 08/75] fix path and breadcrumbs snapshot tests --- app/util/__snapshots__/path-builder.spec.ts.snap | 6 ++++++ app/util/path-builder.spec.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 2393aa324b..d1703a6807 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/path-builder.spec.ts b/app/util/path-builder.spec.ts index 202994c026..c1c4fa264b 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", From c6f5d9678cc884ff64b47e21806f24f490815c5d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 4 Aug 2025 21:36:55 -0500 Subject: [PATCH 09/75] take out json stuff and row expansion --- .../AuditLog.tsx} | 194 ++++-------------- app/routes.tsx | 2 +- 2 files changed, 42 insertions(+), 154 deletions(-) rename app/pages/{SiloAuditLogsPage.tsx => system/AuditLog.tsx} (52%) diff --git a/app/pages/SiloAuditLogsPage.tsx b/app/pages/system/AuditLog.tsx similarity index 52% rename from app/pages/SiloAuditLogsPage.tsx rename to app/pages/system/AuditLog.tsx index 1414c31a4b..fc3d0a0a01 100644 --- a/app/pages/SiloAuditLogsPage.tsx +++ b/app/pages/system/AuditLog.tsx @@ -10,7 +10,7 @@ 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 { useMemo, useRef } from 'react' import { api } from '@oxide/api' import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react' @@ -25,93 +25,12 @@ import { Spinner } from '~/ui/lib/Spinner' import { toSyslogDateString, toSyslogTimeString } from '~/util/date' import { docLinks } from '~/util/links' -// silly faux highlighting -// avoids unnecessary import of a library and all that overhead -const HighlightJSON = memo(({ jsonString }: { jsonString: string }) => { - const Indent = ({ depth }: { depth: number }) => ( - - ) - - const Primitive = ({ value }: { value: null | boolean | number | string }) => ( - - {value === null ? 'null' : typeof value === 'string' ? `"${value}"` : String(value)} - - ) - - const renderValue = ( - value: null | boolean | number | string | object, - depth = 0 - ): React.ReactNode => { - if ( - value === null || - typeof value === 'boolean' || - typeof value === 'number' || - typeof value === 'string' - ) { - return - } - - if (Array.isArray(value)) { - if (value.length === 0) return [] - - return ( - <> - [ - {'\n'} - {value.map((item, index) => ( - - - {renderValue(item, depth + 1)} - {index < value.length - 1 && ,} - {'\n'} - - ))} - - ] - - ) - } - - if (typeof value === 'object') { - const entries = Object.entries(value) - if (entries.length === 0) return {'{}'} - - return ( - <> - {'{'} - {'\n'} - {entries.map(([key, val], index) => ( - - - {key} - : - {renderValue(val, depth + 1)} - {index < entries.length - 1 && ,} - {'\n'} - - ))} - - {'}'} - - ) - } - - return String(value) - } - - try { - const parsed = JSON.parse(jsonString) - return <>{renderValue(parsed)} - } catch { - return <>{jsonString} - } -}) - export const handle = { crumb: 'Audit Log' } -export default function SiloAuditLogsPage() { - const [expandedItem, setExpandedItem] = useState(null) +// for virtualizer +const estimateSize = () => 36 +export default function SiloAuditLogsPage() { // pass refetch interval to this to keep the date up to date const { preset, startTime, endTime, dateTimeRangePicker, onRangeChange } = useDateTimeRangePicker({ @@ -161,28 +80,13 @@ export default function SiloAuditLogsPage() { const parentRef = useRef(null) - const EXPANDED_HEIGHT = 282 - const rowVirtualizer = useVirtualizer({ count: auditLogs.length, getScrollElement: () => document.querySelector('#scroll-container'), - estimateSize: useCallback( - (index) => { - return expandedItem === index.toString() ? 36 + EXPANDED_HEIGHT : 36 - }, - [expandedItem, EXPANDED_HEIGHT] - ), + estimateSize, overscan: 20, }) - const handleToggle = useCallback( - (index: string | null) => { - setExpandedItem(index) - rowVirtualizer.measure() - }, - [rowVirtualizer] - ) - const LogTable = () => ( <>
{rowVirtualizer.getVirtualItems().map((virtualRow) => { const log = auditLogs[virtualRow.index] - const isExpanded = expandedItem === virtualRow.index.toString() - const jsonString = JSON.stringify(log, null, 2) return (
-
- - {isExpanded && ( -
-
-                      
-                    
-
- )} + )} +
+
maze-war
+
+ {differenceInMilliseconds(new Date(log.timeCompleted), log.timeStarted)} + ms +
) diff --git a/app/routes.tsx b/app/routes.tsx index 7e371f3e72..0b4c2a7b8e 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -211,7 +211,7 @@ export const routes = createRoutesFromElements(
import('./pages/SiloAuditLogsPage').then(convert)} + lazy={() => import('./pages/system/AuditLog').then(convert)} /> From 09b1df89f0175434be8231f5cdd6e0b5abdd0aae Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 4 Aug 2025 22:32:45 -0500 Subject: [PATCH 10/75] don't define components in render --- app/pages/system/AuditLog.tsx | 44 ++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index fc3d0a0a01..b5f5cef911 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -22,11 +22,25 @@ 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 { classed } from '~/util/classed' import { toSyslogDateString, toSyslogTimeString } from '~/util/date' import { docLinks } from '~/util/links' export const handle = { crumb: 'Audit Log' } +// todo +// might want to still render the items in case of error +const ErrorState = () => { + return
Error State
+} + +// todo +const LoadingState = () => { + return
Loading State
+} + +const HeaderCell = classed.div`text-mono-sm text-tertiary` + // for virtualizer const estimateSize = () => 36 @@ -87,7 +101,7 @@ export default function SiloAuditLogsPage() { overscan: 20, }) - const LogTable = () => ( + const logTable = ( <>
{}} >
@@ -172,17 +185,6 @@ export default function SiloAuditLogsPage() { ) - // todo - // might want to still render the items in case of error - const ErrorState = () => { - return
Error State
- } - - // todo - const LoadingState = () => { - return
Loading State
- } - return ( <> @@ -206,18 +208,18 @@ export default function SiloAuditLogsPage() { gridTemplateColumns: '7rem 4.25rem 180px 120px 120px 120px 300px 300px', }} > - {['Time', 'Status', 'Operation', 'Actor', 'Access Method', 'Silo', 'Duration'].map( - (header) => ( -
- {header} -
- ) - )} + Time + Status + Operation + Actor + Access Method + Silo + Duration
- {error ? : !isLoading ? : } + {error ? : !isLoading ? logTable : }
From 98ccb6209b83802e30ddc2894b5f25b3a7837df3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 4 Aug 2025 23:34:47 -0500 Subject: [PATCH 11/75] make fewer than 200000 mock audit log entries (fix test failure) --- mock-api/audit-log.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts index 371104ac5c..fca1389986 100644 --- a/mock-api/audit-log.ts +++ b/mock-api/audit-log.ts @@ -201,5 +201,5 @@ export const auditLogs: Json = [ source_ip: '192.168.1.100', }, // Generate additional entries - ...Array.from({ length: 199995 }, (_, i) => generateAuditLogEntry(i + 5)), + ...Array.from({ length: 4995 }, (_, i) => generateAuditLogEntry(i + 5)), ] From 28e9807ecaaffbbefc93a744426be048b48f0ff1 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 5 Aug 2025 10:04:21 -0500 Subject: [PATCH 12/75] rename auditLogs to auditLog everywhere --- app/pages/system/AuditLog.tsx | 10 +++++----- app/util/links.ts | 6 +++--- mock-api/audit-log.ts | 2 +- mock-api/msw/db.ts | 2 +- mock-api/msw/handlers.ts | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index b5f5cef911..1fa0c6ea51 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -88,14 +88,14 @@ export default function SiloAuditLogsPage() { placeholderData: (x) => x, }) - const auditLogs = useMemo(() => { + const allItems = useMemo(() => { return data?.pages.flatMap((page) => page.items) || [] }, [data]) const parentRef = useRef(null) const rowVirtualizer = useVirtualizer({ - count: auditLogs.length, + count: allItems.length, getScrollElement: () => document.querySelector('#scroll-container'), estimateSize, overscan: 20, @@ -110,7 +110,7 @@ export default function SiloAuditLogsPage() { }} > {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const log = auditLogs[virtualRow.index] + const log = allItems[virtualRow.index] return (
- {!hasNextPage && !isFetching && !isPending && auditLogs.length > 0 ? ( + {!hasNextPage && !isFetching && !isPending && allItems.length > 0 ? (
No more logs to show within selected timeline
@@ -193,7 +193,7 @@ export default function SiloAuditLogsPage() { heading="audit log" icon={} summary="The audit log provides a record of system activities, including user actions, API calls, and system events." - links={[docLinks.auditLogs]} + links={[docLinks.auditLog]} /> diff --git a/app/util/links.ts b/app/util/links.ts index 29c2245eed..e2fb02ea80 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -12,7 +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', - auditLogsDocs: 'https://docs.oxide.computer/guides/audit-logs', + 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: @@ -76,8 +76,8 @@ export const docLinks = { href: links.affinityDocs, linkText: 'Anti-Affinity Groups', }, - auditLogs: { - href: links.auditLogsDocs, + auditLog: { + href: links.auditLogDocs, linkText: 'Audit Logs', }, deviceTokens: { diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts index fca1389986..7f8204729e 100644 --- a/mock-api/audit-log.ts +++ b/mock-api/audit-log.ts @@ -110,7 +110,7 @@ function generateAuditLogEntry(index: number): Json { } } -export const auditLogs: Json = [ +export const auditLog: Json = [ // Recent successful operations { id: uuid(), diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 329b9366d4..630e83e961 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -483,7 +483,7 @@ const initDb = { affinityGroupMemberLists: [...mock.affinityGroupMemberLists], antiAffinityGroups: [...mock.antiAffinityGroups], antiAffinityGroupMemberLists: [...mock.antiAffinityGroupMemberLists], - auditLogs: [...mock.auditLogs], + 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 5be5c641f5..e5dc831c53 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1791,7 +1791,7 @@ export const handlers = makeHandlers({ return paginated(query, affinityGroups) }, auditLogList: ({ query }) => { - let filteredLogs = db.auditLogs + let filteredLogs = db.auditLog if (query.startTime) { filteredLogs = filteredLogs.filter( From f0ab97b38044eec7106120344974880a4a0d8593 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 5 Aug 2025 14:59:25 -0500 Subject: [PATCH 13/75] show user and silo IDs with middle truncation --- app/pages/system/AuditLog.tsx | 63 +++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 1fa0c6ea51..f1b202755c 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -11,6 +11,7 @@ import { useVirtualizer } from '@tanstack/react-virtual' import cn from 'classnames' import { differenceInMilliseconds } from 'date-fns' import { useMemo, useRef } from 'react' +import { match } from 'ts-pattern' import { api } from '@oxide/api' import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react' @@ -18,10 +19,12 @@ 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' @@ -39,6 +42,10 @@ const LoadingState = () => { return
Loading State
} +const colWidths = { + gridTemplateColumns: '7rem 4.25rem 180px 140px 120px 140px 300px 300px', +} + const HeaderCell = classed.div`text-mono-sm text-tertiary` // for virtualizer @@ -112,6 +119,12 @@ export default function SiloAuditLogsPage() { {rowVirtualizer.getVirtualItems().map((virtualRow) => { const log = allItems[virtualRow.index] + 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 (
+ {/* TODO: might be especially useful here to get the original UTC timestamp in a tooltip */}
{toSyslogDateString(log.timeCompleted)} @@ -141,19 +153,37 @@ export default function SiloAuditLogsPage() { 200
- - {log.operationId.split('_').join(' ')} - + {log.operationId.split('_').join(' ')} +
+
+ {userId ? ( + + ) : ( + + )}
-
hannah.arendt
- {!!log.accessMethod && ( - - {log.accessMethod.split('_').join(' ')} - + + {log.accessMethod?.split('_').join(' ') || 'Unknown'} + +
+
+ {siloId ? ( + + ) : ( + )}
-
maze-war
{differenceInMilliseconds(new Date(log.timeCompleted), log.timeStarted)} ms @@ -204,16 +234,15 @@ export default function SiloAuditLogsPage() {
+ {/* TODO: explain that this is time completed, not time started as you might expect */} Time Status Operation - Actor + Actor ID Access Method - Silo + Silo ID Duration
From 001853152fb50e246ffe3dd95a7ceeb6b961b5ce Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 5 Aug 2025 16:56:01 -0500 Subject: [PATCH 14/75] put back the expandable JSON thing and fix the height --- app/pages/system/AuditLog.tsx | 125 ++++++++++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 7 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index f1b202755c..14490bbec8 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -10,7 +10,7 @@ import { useInfiniteQuery, useIsFetching } from '@tanstack/react-query' import { useVirtualizer } from '@tanstack/react-virtual' import cn from 'classnames' import { differenceInMilliseconds } from 'date-fns' -import { useMemo, useRef } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { match } from 'ts-pattern' import { api } from '@oxide/api' @@ -31,6 +31,88 @@ import { docLinks } from '~/util/links' export const handle = { crumb: 'Audit Log' } +const Indent = ({ depth }: { depth: number }) => ( + +) + +const Primitive = ({ value }: { value: null | boolean | number | string }) => ( + + {value === null ? 'null' : typeof value === 'string' ? `"${value}"` : String(value)} + +) + +// silly faux highlighting +// avoids unnecessary import of a library and all that overhead +const HighlightJSON = memo(({ jsonString }: { jsonString: string }) => { + const renderValue = ( + value: null | boolean | number | string | object, + depth = 0 + ): React.ReactNode => { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + return + } + + if (Array.isArray(value)) { + if (value.length === 0) return [] + + return ( + <> + [ + {'\n'} + {value.map((item, index) => ( + + + {renderValue(item, depth + 1)} + {index < value.length - 1 && ,} + {'\n'} + + ))} + + ] + + ) + } + + if (typeof value === 'object') { + const entries = Object.entries(value) + if (entries.length === 0) return {'{}'} + + return ( + <> + {'{'} + {'\n'} + {entries.map(([key, val], index) => ( + + + {key} + : + {renderValue(val, depth + 1)} + {index < entries.length - 1 && ,} + {'\n'} + + ))} + + {'}'} + + ) + } + + return String(value) + } + + try { + const parsed = JSON.parse(jsonString) + return <>{renderValue(parsed)} + } catch { + return <>{jsonString} + } +}) + // todo // might want to still render the items in case of error const ErrorState = () => { @@ -48,10 +130,11 @@ const colWidths = { const HeaderCell = classed.div`text-mono-sm text-tertiary` -// for virtualizer -const estimateSize = () => 36 +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({ @@ -104,10 +187,23 @@ export default function SiloAuditLogsPage() { const rowVirtualizer = useVirtualizer({ count: allItems.length, getScrollElement: () => document.querySelector('#scroll-container'), - estimateSize, + 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() + const jsonString = JSON.stringify(log, null, 2) const [userId, siloId] = match(log.actor) .with({ kind: 'silo_user' }, (actor) => [actor.siloUserId, actor.siloId]) @@ -134,12 +232,18 @@ export default function SiloAuditLogsPage() { transform: `translateY(${virtualRow.start}px)`, }} > -
{ + const newValue = isExpanded ? null : virtualRow.index.toString() + handleToggle(newValue) + }} + type="button" > {/* TODO: might be especially useful here to get the original UTC timestamp in a tooltip */}
@@ -188,7 +292,14 @@ export default function SiloAuditLogsPage() { {differenceInMilliseconds(new Date(log.timeCompleted), log.timeStarted)} ms
-
+ + {isExpanded && ( +
+
+                    
+                  
+
+ )}
) })} From 0f6bde13a6c4919fd911b5b95396408bed8cab11 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 5 Aug 2025 17:42:34 -0500 Subject: [PATCH 15/75] request_uri is the full URI --- mock-api/audit-log.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts index 7f8204729e..859e1c2544 100644 --- a/mock-api/audit-log.ts +++ b/mock-api/audit-log.ts @@ -105,7 +105,7 @@ function generateAuditLogEntry(index: number): Json { request_id: mockRequestIds[index % mockRequestIds.length], time_started: baseTime.toISOString(), time_completed: completedTime.toISOString(), - request_uri: `/v1/projects/default/${operation.replace('_', '/')}`, + request_uri: `https://maze-war.sys.corp.rack/v1/projects/default/${operation.replace('_', '/')}`, source_ip: mockSourceIps[index % mockSourceIps.length], } } @@ -125,7 +125,7 @@ export const auditLog: Json = [ 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: '/v1/projects/admin-project/instances', + request_uri: 'https://maze-war.sys.corp.rack/v1/projects/admin-project/instances', source_ip: '192.168.1.100', }, { @@ -141,7 +141,8 @@ export const auditLog: Json = [ 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: '/v1/projects/admin-project/instances/web-server-prod/start', + 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 @@ -163,7 +164,8 @@ export const auditLog: Json = [ 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: '/v1/projects/dev-project/instances/test-instance', + request_uri: + 'https://maze-war.sys.corp.rack/v1/projects/dev-project/instances/test-instance', source_ip: '172.16.0.25', }, { @@ -180,7 +182,7 @@ export const auditLog: Json = [ 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: '/v1/login', + request_uri: 'https://maze-war.sys.corp.rack/v1/login', source_ip: '203.0.113.15', }, // More historical entries @@ -197,7 +199,7 @@ export const auditLog: Json = [ 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: '/v1/projects', + request_uri: 'https://maze-war.sys.corp.rack/v1/projects', source_ip: '192.168.1.100', }, // Generate additional entries From f50cc3a24004a2f0de97a82d13f7f518a46ee08c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 5 Aug 2025 17:48:13 -0500 Subject: [PATCH 16/75] camelToSnakeJson for json body --- app/pages/system/AuditLog.tsx | 48 ++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 14490bbec8..f35ae575dc 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -31,6 +31,42 @@ 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 = {} + + 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 }) => ( ) @@ -41,6 +77,8 @@ const Primitive = ({ value }: { value: null | boolean | number | string }) => ( ) +// TODO: avoid converting JSON to string and then parsing again. just need a better memo + // silly faux highlighting // avoids unnecessary import of a library and all that overhead const HighlightJSON = memo(({ jsonString }: { jsonString: string }) => { @@ -215,7 +253,10 @@ export default function SiloAuditLogsPage() { {rowVirtualizer.getVirtualItems().map((virtualRow) => { const log = allItems[virtualRow.index] const isExpanded = expandedItem === virtualRow.index.toString() - const jsonString = JSON.stringify(log, null, 2) + // only bother doing all this computation if we're the expanded row + const jsonString = isExpanded + ? JSON.stringify(camelToSnakeJson(log), null, 2) + : '' const [userId, siloId] = match(log.actor) .with({ kind: 'silo_user' }, (actor) => [actor.siloUserId, actor.siloId]) @@ -232,7 +273,7 @@ export default function SiloAuditLogsPage() { transform: `translateY(${virtualRow.start}px)`, }} > - +
{isExpanded && (

From 849e425c26dce53059cb4fc2adb7a4e849e5b7c4 Mon Sep 17 00:00:00 2001
From: David Crespo 
Date: Wed, 6 Aug 2025 11:15:20 -0500
Subject: [PATCH 17/75] make HighlightJSON a normal recursive component

---
 app/pages/system/AuditLog.tsx | 128 +++++++++++++++-------------------
 1 file changed, 56 insertions(+), 72 deletions(-)

diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx
index f35ae575dc..f8b6e2087d 100644
--- a/app/pages/system/AuditLog.tsx
+++ b/app/pages/system/AuditLog.tsx
@@ -12,6 +12,7 @@ import cn from 'classnames'
 import { differenceInMilliseconds } from 'date-fns'
 import { memo, useCallback, useMemo, useRef, useState } from 'react'
 import { match } from 'ts-pattern'
+import { JsonValue } from 'type-fest'
 
 import { api } from '@oxide/api'
 import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react'
@@ -77,78 +78,63 @@ const Primitive = ({ value }: { value: null | boolean | number | string }) => (
   
 )
 
-// TODO: avoid converting JSON to string and then parsing again. just need a better memo
-
-// silly faux highlighting
-// avoids unnecessary import of a library and all that overhead
-const HighlightJSON = memo(({ jsonString }: { jsonString: string }) => {
-  const renderValue = (
-    value: null | boolean | number | string | object,
-    depth = 0
-  ): React.ReactNode => {
-    if (
-      value === null ||
-      typeof value === 'boolean' ||
-      typeof value === 'number' ||
-      typeof value === 'string'
-    ) {
-      return 
-    }
-
-    if (Array.isArray(value)) {
-      if (value.length === 0) return []
+// 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'
+  ) {
+    return 
+  }
 
-      return (
-        <>
-          [
-          {'\n'}
-          {value.map((item, index) => (
-            
-              
-              {renderValue(item, depth + 1)}
-              {index < value.length - 1 && ,}
-              {'\n'}
-            
-          ))}
-          
-          ]
-        
-      )
-    }
+  if (Array.isArray(json)) {
+    if (json.length === 0) return []
+
+    return (
+      <>
+        [
+        {'\n'}
+        {json.map((item, index) => (
+          
+            
+            
+            {index < json.length - 1 && ,}
+            {'\n'}
+          
+        ))}
+        
+        ]
+      
+    )
+  }
 
-    if (typeof value === 'object') {
-      const entries = Object.entries(value)
-      if (entries.length === 0) return {'{}'}
+  const entries = Object.entries(json)
+  if (entries.length === 0) return {'{}'}
 
-      return (
-        <>
-          {'{'}
+  return (
+    <>
+      {'{'}
+      {'\n'}
+      {entries.map(([key, val], index) => (
+        
+          
+          {key}
+          : 
+          
+          {index < entries.length - 1 && ,}
           {'\n'}
-          {entries.map(([key, val], index) => (
-            
-              
-              {key}
-              : 
-              {renderValue(val, depth + 1)}
-              {index < entries.length - 1 && ,}
-              {'\n'}
-            
-          ))}
-          
-          {'}'}
-        
-      )
-    }
-
-    return String(value)
-  }
-
-  try {
-    const parsed = JSON.parse(jsonString)
-    return <>{renderValue(parsed)}
-  } catch {
-    return <>{jsonString}
-  }
+        
+      ))}
+      
+      {'}'}
+    
+  )
 })
 
 // todo
@@ -254,9 +240,7 @@ export default function SiloAuditLogsPage() {
           const log = allItems[virtualRow.index]
           const isExpanded = expandedItem === virtualRow.index.toString()
           // only bother doing all this computation if we're the expanded row
-          const jsonString = isExpanded
-            ? JSON.stringify(camelToSnakeJson(log), null, 2)
-            : ''
+          const json = isExpanded ? camelToSnakeJson(log) : undefined
 
           const [userId, siloId] = match(log.actor)
             .with({ kind: 'silo_user' }, (actor) => [actor.siloUserId, actor.siloId])
@@ -336,7 +320,7 @@ export default function SiloAuditLogsPage() {
               {isExpanded && (
                 
-                    
+                    
                   
)} From 3d1b37abc0ddd11489d54d189b43ae2c6dbb5a98 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 6 Aug 2025 12:06:22 -0500 Subject: [PATCH 18/75] fix date rendering --- app/pages/system/AuditLog.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index f8b6e2087d..66e9f3db3c 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -40,6 +40,8 @@ export const handle = { crumb: 'Audit Log' } 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 @@ -72,9 +74,15 @@ const Indent = ({ depth }: { depth: number }) => ( ) -const Primitive = ({ value }: { value: null | boolean | number | string }) => ( +const Primitive = ({ value }: { value: null | boolean | number | string | Date }) => ( - {value === null ? 'null' : typeof value === 'string' ? `"${value}"` : String(value)} + {value === null + ? 'null' + : typeof value === 'string' + ? `"${value}"` + : value instanceof Date + ? `"${value.toISOString()}"` + : String(value)} ) @@ -88,7 +96,10 @@ const HighlightJSON = memo(({ json, depth = 0 }: { json: JsonValue; depth?: numb json === null || typeof json === 'boolean' || typeof json === 'number' || - typeof json === 'string' + 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 } From ea4142a90caf41c23da4b33a2e54c03355e2b7b2 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 6 Aug 2025 12:30:35 -0500 Subject: [PATCH 19/75] fix a11y lint error by making rows focusable and interactive --- app/pages/system/AuditLog.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 66e9f3db3c..5e8a523ae1 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -12,7 +12,7 @@ import cn from 'classnames' import { differenceInMilliseconds } from 'date-fns' import { memo, useCallback, useMemo, useRef, useState } from 'react' import { match } from 'ts-pattern' -import { JsonValue } from 'type-fest' +import { type JsonValue } from 'type-fest' import { api } from '@oxide/api' import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react' @@ -279,6 +279,15 @@ export default function SiloAuditLogsPage() { 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 */}
From 4703c88a281e980de9f313d2cd73417475c67c6a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 15 Aug 2025 12:27:17 -0500 Subject: [PATCH 20/75] sort by time descending in API call, fix timestamp col width --- app/pages/system/AuditLog.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 1824a76ea4..2ba07213b2 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -14,7 +14,7 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react' import { match } from 'ts-pattern' import { type JsonValue } from 'type-fest' -import { api } from '@oxide/api' +import { api, AuditLogListQueryParams } from '@oxide/api' import { Logs16Icon, Logs24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -160,7 +160,7 @@ const LoadingState = () => { } const colWidths = { - gridTemplateColumns: '7rem 4.25rem 180px 140px 120px 140px 300px 300px', + gridTemplateColumns: '7.5rem 4.25rem 180px 140px 120px 140px 300px 300px', } const HeaderCell = classed.div`text-mono-sm text-tertiary` @@ -184,10 +184,11 @@ export default function SiloAuditLogsPage() { fn: () => onRangeChange(preset), }) - const queryParams = { + const queryParams: AuditLogListQueryParams = { startTime, endTime, limit: 500, + sortBy: 'time_and_id_descending', } const { @@ -393,8 +394,7 @@ export default function SiloAuditLogsPage() { className="sticky top-0 z-10 !mx-0 grid !w-full items-center gap-8 border-b px-[var(--content-gutter)] pb-2 pt-4 bg-default border-secondary" style={colWidths} > - {/* TODO: explain that this is time completed, not time started as you might expect */} - Time + Time Completed Status Operation Actor ID From 0455abe72caf3a8251a773df72b076528c0e7fc0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 15 Aug 2025 12:48:07 -0500 Subject: [PATCH 21/75] real status code display and remove POST --- app/pages/system/AuditLog.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 2ba07213b2..b754ea5e84 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -11,7 +11,7 @@ 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 } from 'ts-pattern' +import { match, P } from 'ts-pattern' import { type JsonValue } from 'type-fest' import { api, AuditLogListQueryParams } from '@oxide/api' @@ -159,8 +159,18 @@ 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 4.25rem 180px 140px 120px 140px 300px 300px', + gridTemplateColumns: '7.5rem 3rem 180px 140px 120px 140px 300px 300px', } const HeaderCell = classed.div`text-mono-sm text-tertiary` @@ -298,8 +308,12 @@ export default function SiloAuditLogsPage() { {toSyslogTimeString(log.timeCompleted)}
- POST - 200 + {match(log.result) + .with(P.union({ kind: 'success' }, { kind: 'error' }), (result) => ( + + )) + .with({ kind: 'unknown' }, () => ) + .exhaustive()}
{log.operationId.split('_').join(' ')} From 11f173df3eaa92a569f98fbc55aa56281d1bf554 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Fri, 22 Aug 2025 11:11:29 +0100 Subject: [PATCH 22/75] Fix overflow --- app/pages/system/AuditLog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index b754ea5e84..8ffb45fca0 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -170,7 +170,7 @@ function StatusCodeCell({ code }: { code: number }) { } const colWidths = { - gridTemplateColumns: '7.5rem 3rem 180px 140px 120px 140px 300px 300px', + gridTemplateColumns: '7.5rem 3rem 180px 140px 120px 140px 1fr', } const HeaderCell = classed.div`text-mono-sm text-tertiary` From 7ff174ff60a8e7f09af75163a3956613c7c8ad48 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Mon, 22 Sep 2025 15:44:12 +0100 Subject: [PATCH 23/75] Pane and loading state --- app/pages/system/AuditLog.tsx | 333 ++++++++++++++++++++++++++------- app/ui/lib/CopyToClipboard.tsx | 3 +- app/util/math.ts | 7 + tailwind.config.ts | 5 +- 4 files changed, 282 insertions(+), 66 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 8ffb45fca0..13dbdba360 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -14,8 +14,14 @@ 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 { api, type AuditLogEntry, type AuditLogListQueryParams } from '@oxide/api' +import { + Close12Icon, + Logs16Icon, + Logs24Icon, + NextArrow12Icon, + PrevArrow12Icon, +} from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' @@ -23,12 +29,16 @@ 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 { toSyslogDateString, toSyslogTimeString } from '~/util/date' +import { toLocaleDateString, toSyslogDateString, toSyslogTimeString } from '~/util/date' import { docLinks } from '~/util/links' +import { deterRandom } from '~/util/math' export const handle = { crumb: 'Audit Log' } @@ -154,29 +164,91 @@ const ErrorState = () => { return
Error State
} -// todo const LoadingState = () => { - return
Loading State
+ 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 < 400 - ? 'default' - : code >= 400 && code < 500 - ? 'notice' - : 'destructive' + const color = code >= 200 && code < 500 ? 'default' : 'destructive' return {code} } const colWidths = { - gridTemplateColumns: '7.5rem 3rem 180px 140px 120px 140px 1fr', + gridTemplateColumns: '7.75rem 3rem 160px 130px 120px 130px 1fr', } 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) @@ -233,19 +305,16 @@ export default function SiloAuditLogsPage() { 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, + estimateSize: () => 36, + overscan: 40, }) const handleToggle = useCallback( (index: string | null) => { setExpandedItem(index) - rowVirtualizer.measure() + setTimeout(() => { + rowVirtualizer.measure() + }, 0) }, [rowVirtualizer] ) @@ -260,9 +329,8 @@ export default function SiloAuditLogsPage() { > {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 indexStr = virtualRow.index.toString() + const isExpanded = expandedItem === indexStr const [userId, siloId] = match(log.actor) .with({ kind: 'silo_user' }, (actor) => [actor.siloUserId, actor.siloId]) @@ -281,20 +349,19 @@ export default function SiloAuditLogsPage() { >
{ - const newValue = isExpanded ? null : virtualRow.index.toString() - handleToggle(newValue) + 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 === ' ') { - const newValue = isExpanded ? null : virtualRow.index.toString() - handleToggle(newValue) + handleToggle(indexStr) } }} role="button" // oxlint-disable-line prefer-tag-over-role @@ -354,13 +421,6 @@ export default function SiloAuditLogsPage() { ms
- {isExpanded && ( -
-
-                    
-                  
-
- )}
) })} @@ -387,41 +447,186 @@ export default function SiloAuditLogsPage() { ) + const selectedItem = expandedItem ? allItems[parseInt(expandedItem, 10)] : null + 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 +
+ + }>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)} + /> + ) + })()} +
{error ? : !isLoading ? 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 +}) => { + 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/ui/lib/CopyToClipboard.tsx b/app/ui/lib/CopyToClipboard.tsx index f00b2523a2..7330842769 100644 --- a/app/ui/lib/CopyToClipboard.tsx +++ b/app/ui/lib/CopyToClipboard.tsx @@ -35,7 +35,8 @@ export const CopyToClipboard = ({ useTimeout(() => setHasCopied(false), hasCopied ? 2000 : null) - const handleCopy = () => { + const handleCopy = (event: React.MouseEvent) => { + event.stopPropagation() window.navigator.clipboard.writeText(text).then(() => { setHasCopied(true) }) diff --git a/app/util/math.ts b/app/util/math.ts index 6487313e5e..3b730f5bbf 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/tailwind.config.ts b/tailwind.config.ts index 967c8f030d..dd2768389d 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' }, }, }, From 42fbfd0a8266dc4b22a80716bd56eb1f71c66a4f Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Mon, 22 Sep 2025 16:44:19 +0100 Subject: [PATCH 24/75] Error state --- app/pages/system/AuditLog.tsx | 53 +++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 13dbdba360..aa50973aee 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -10,13 +10,14 @@ 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 { 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, @@ -158,10 +159,23 @@ const HighlightJSON = memo(({ json, depth = 0 }: { json: JsonValue; depth?: numb ) }) -// todo -// might want to still render the items in case of error -const ErrorState = () => { - return
Error State
+const ErrorState = ({ error, onDismiss }: { error: string; onDismiss: () => void }) => { + return ( +
+
+ + {error} +
+ +
+ ) } const LoadingState = () => { @@ -251,6 +265,7 @@ 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 } = @@ -269,7 +284,6 @@ export default function SiloAuditLogsPage() { const queryParams: AuditLogListQueryParams = { startTime, endTime, - limit: 500, sortBy: 'time_and_id_descending', } @@ -296,6 +310,11 @@ export default function SiloAuditLogsPage() { 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]) @@ -449,6 +468,9 @@ export default function SiloAuditLogsPage() { const selectedItem = expandedItem ? allItems[parseInt(expandedItem, 10)] : null + const errorMessage = error?.message ?? 'An error occurred while loading audit logs' + const showError = error && !dismissedError + return ( <>
@@ -463,7 +485,7 @@ export default function SiloAuditLogsPage() {
-
{intervalPicker}
+ {intervalPicker}
{dateTimeRangePicker}
@@ -495,6 +517,7 @@ export default function SiloAuditLogsPage() { return ( - {error ? : !isLoading ? logTable : } + {showError && ( + setDismissedError(true)} /> + )} + {!isLoading ? logTable : }
@@ -521,6 +547,7 @@ const ExpandedItem = ({ totalCount, onNavigate, onClose, + hasError = false, }: { item: AuditLogEntry userId?: string @@ -529,12 +556,20 @@ const ExpandedItem = ({ totalCount: number onNavigate: (index: number) => void onClose: () => void + hasError: boolean }) => { const snakeJson = camelToSnakeJson(item) const json = JSON.stringify(snakeJson, null, 2) return ( -
+
)}
From f07b8fe6a055847d127eb21c0d797b836d06d2e5 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Mon, 18 May 2026 15:29:55 +0100 Subject: [PATCH 34/75] Make `copyToClipboard` scale less dramatic --- app/ui/lib/CopyToClipboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/lib/CopyToClipboard.tsx b/app/ui/lib/CopyToClipboard.tsx index a4d2934ff1..fcaec69354 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 }, } From 01a735cc3d833786bb6307bf23557ac539492b5c Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 20 May 2026 12:08:11 +0100 Subject: [PATCH 35/75] Overlap expanded item over header row also --- app/pages/system/AuditLog.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 2e33cbb993..bdc29e72c6 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -633,7 +633,7 @@ export default function SiloAuditLogsPage() {
-
+
@@ -712,13 +712,13 @@ const ExpandedItem = ({ return (
-
+
) }) From 0825cb7430a23f88bd6c97657b546e1a0106ef28 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 3 Jul 2026 15:10:01 +0100 Subject: [PATCH 49/75] Arrow keys to adjust focus without modal --- app/pages/system/AuditLog.tsx | 54 +++++++++++++++++------------------ 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index d686bda265..10dfe3d163 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -292,15 +292,7 @@ const Row = memo(function Row({ isExpanded ? 'bg-hover' : 'hover:bg-raise' )} onClick={() => onToggle(index)} - // TODO: some of the focusing behaviour and repetitive code needs work - // a11y thing: make it focusable and let the user press enter on it to toggle - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - onToggle(index) - } - }} type="button" - tabIndex={0} data-row-index={index} > {/* TODO: might be especially useful here to get the original UTC timestamp in a tooltip */} @@ -471,40 +463,46 @@ export default function SiloAuditLogsPage() { }, []) // arrow keys move selection (and focus) between rows; escape closes the - // modal. adjacent rows are within the virtualizer's overscan, so they're - // already in the DOM when we look them up. + // modal. with the modal closed, arrows still move focus when a row is + // focused, without opening the modal. adjacent rows are within the + // virtualizer's overscan, so they're already in the DOM when we look them up. useEffect(() => { - if (expandedItem === null) return const onKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null // don't hijack typing in inputs (e.g. the date pickers above the list) if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return - const currentIdx = parseInt(expandedItem, 10) - if (e.key === 'ArrowDown') { - e.preventDefault() - const next = currentIdx + 1 - if (next < allItems.length) { - navigateToIndex(next) - focusRow(next) - } - } else if (e.key === 'ArrowUp') { - e.preventDefault() - const prev = currentIdx - 1 - if (prev >= 0) { - navigateToIndex(prev) - focusRow(prev) - } - } else if (e.key === 'Escape') { + // 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 = parseInt( + expandedItem ?? + target?.closest('[data-row-index]')?.getAttribute('data-row-index') ?? + '', + 10 + ) + if (Number.isNaN(currentIdx)) return + + if (e.key === 'Escape' && expandedItem !== null) { e.preventDefault() handleToggle(null) // restore focus to the row in case focus was inside the modal focusRow(currentIdx) + return } + + const delta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 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) handleToggle(next.toString()) + scrollToRow(next) + focusRow(next) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [expandedItem, allItems.length, handleToggle, navigateToIndex, focusRow]) + }, [expandedItem, allItems.length, handleToggle, focusRow, scrollToRow]) const logTable = ( <> From 201464ade60e18877b1d9417b4ee68436a1fafb6 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 3 Jul 2026 15:17:45 +0100 Subject: [PATCH 50/75] Revert switch to button --- app/pages/system/AuditLog.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 10dfe3d163..085c0bc3bc 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -285,14 +285,22 @@ const Row = memo(function Row({ transform: `translateY(${start - scrollMargin}px)`, }} > -
- +
) }) From 15d3b1fad3c7b662ce298ece5e9ae0e48321aca3 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 3 Jul 2026 15:19:37 +0100 Subject: [PATCH 51/75] Fix guide link --- app/util/links.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/util/links.ts b/app/util/links.ts index e41e9ebdf9..eb343f39ae 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -41,7 +41,7 @@ export const docLinks = { linkText: 'Anti-Affinity Groups', }, auditLog: { - href: 'https://docs.oxide.computer/guides/audit-logs', + href: 'https://docs.oxide.computer/guides/audit-log', linkText: 'Audit Logs', }, deviceTokens: { From 7c6e9d137ce88ee596efcc3261f42310e8abd143 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 3 Jul 2026 16:05:37 +0100 Subject: [PATCH 52/75] Use rando --- mock-api/audit-log.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts index b1d5f81c17..89dafbb7b5 100644 --- a/mock-api/audit-log.ts +++ b/mock-api/audit-log.ts @@ -11,6 +11,7 @@ import { v4 as uuid } from 'uuid' import type { AuditLogEntry } from '@oxide/api' import type { Json } from './json-type' +import { Rando } from './msw/rando' import { defaultSilo } from './silo' const mockUserIds = [ @@ -78,6 +79,9 @@ const mockSourceIps = [ 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] @@ -86,9 +90,7 @@ function generateAuditLogEntry(index: number): Json { 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 + completedTime.setMilliseconds(rando.next() * 300 + completedTime.getMilliseconds()) // Deterministic random durations return { id: uuid(), From d18fd4bc25278aa31727c9c2f92e6821b748bb0d Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 3 Jul 2026 16:11:46 +0100 Subject: [PATCH 53/75] Use rando for skeleton rows too --- app/pages/system/AuditLog.tsx | 41 +++++++++++++---------------- app/util/math.ts | 7 ----- {mock-api/msw => app/util}/rando.ts | 0 mock-api/audit-log.ts | 2 +- mock-api/disk.ts | 2 +- mock-api/msw/util.ts | 2 +- mock-api/snapshot.ts | 2 +- 7 files changed, 23 insertions(+), 33 deletions(-) rename {mock-api/msw => app/util}/rando.ts (100%) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 085c0bc3bc..09a3d16e8f 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -47,7 +47,7 @@ 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' +import { Rando } from '~/util/rando' export const handle = { crumb: 'Audit Log' } @@ -151,12 +151,24 @@ const ErrorState = ({ error, onDismiss }: { error: string; onDismiss: () => void ) } +// 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 */}
- {[...Array(50)].map((_, i) => ( + {skeletonRows.map((row, i) => (
{ {/* Operation column */}
-
+
{/* Actor ID column */}
-
+
{/* Auth Method column */}
-
+
{/* Silo ID column */}
-
+
{/* Duration column */}
-
+
))} diff --git a/app/util/math.ts b/app/util/math.ts index a4bbbe381c..d240fa9ad2 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -112,10 +112,3 @@ 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/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 index 89dafbb7b5..708f03cd52 100644 --- a/mock-api/audit-log.ts +++ b/mock-api/audit-log.ts @@ -11,7 +11,7 @@ import { v4 as uuid } from 'uuid' import type { AuditLogEntry } from '@oxide/api' import type { Json } from './json-type' -import { Rando } from './msw/rando' +import { Rando } from '~/util/rando' import { defaultSilo } from './silo' const mockUserIds = [ diff --git a/mock-api/disk.ts b/mock-api/disk.ts index 76f3cb3dbe..9f494f08b7 100644 --- a/mock-api/disk.ts +++ b/mock-api/disk.ts @@ -11,7 +11,7 @@ import { GiB } from '~/util/units' import { instance, stoppedInstance } from './instance' import type { Json } from './json-type' -import { Rando } from './msw/rando' +import { Rando } from '~/util/rando' import { project, project2 } from './project' // Use seeded random for consistent states across runs diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index daa055f442..7f4894e8d0 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -42,7 +42,7 @@ import type { DbRoleAssignmentResourceType } from '..' import { genI64Data } from '../metrics' import { getMockOxqlInstanceData } from '../oxql-metrics' import { db, lookupById } from './db' -import { Rando } from './rando' +import { Rando } from '~/util/rando' interface PaginateOptions { limit?: number | null diff --git a/mock-api/snapshot.ts b/mock-api/snapshot.ts index 3740b29a8a..544044721e 100644 --- a/mock-api/snapshot.ts +++ b/mock-api/snapshot.ts @@ -14,7 +14,7 @@ import { GiB } from '~/util/units' import { disks } from './disk' import type { Json } from './json-type' -import { Rando } from './msw/rando' +import { Rando } from '~/util/rando' import { project } from './project' // Use seeded random for consistent states across runs From d50dd5221c5c2e50885cf25ccbc331b312e16e3e Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 3 Jul 2026 16:18:44 +0100 Subject: [PATCH 54/75] Fix z-index listbox stacking --- app/pages/system/AuditLog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 09a3d16e8f..e7cfa62b2c 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -513,7 +513,7 @@ export default function SiloAuditLogsPage() { <>
-
+
{COLUMNS.map((column) => ( From 3c5e35a540c8ffbbee6e14b32ba08a1b13bf167a Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 3 Jul 2026 16:22:18 +0100 Subject: [PATCH 55/75] Fmt --- mock-api/audit-log.ts | 3 ++- mock-api/disk.ts | 2 +- mock-api/msw/util.ts | 2 +- mock-api/snapshot.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts index 708f03cd52..ab1720e84c 100644 --- a/mock-api/audit-log.ts +++ b/mock-api/audit-log.ts @@ -10,8 +10,9 @@ import { v4 as uuid } from 'uuid' import type { AuditLogEntry } from '@oxide/api' -import type { Json } from './json-type' import { Rando } from '~/util/rando' + +import type { Json } from './json-type' import { defaultSilo } from './silo' const mockUserIds = [ diff --git a/mock-api/disk.ts b/mock-api/disk.ts index 9f494f08b7..589192ba38 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 '~/util/rando' import { project, project2 } from './project' // Use seeded random for consistent states across runs diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 7f4894e8d0..82933c6b58 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -36,13 +36,13 @@ 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 '..' import { genI64Data } from '../metrics' import { getMockOxqlInstanceData } from '../oxql-metrics' import { db, lookupById } from './db' -import { Rando } from '~/util/rando' interface PaginateOptions { limit?: number | null diff --git a/mock-api/snapshot.ts b/mock-api/snapshot.ts index 544044721e..e9758173d1 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 '~/util/rando' import { project } from './project' // Use seeded random for consistent states across runs From 090970745621e2d45a090bc2e4ed3b0f54b1bdaa Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Mon, 6 Jul 2026 18:32:48 +0100 Subject: [PATCH 56/75] Correct audit log docs link --- app/util/links.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/util/links.ts b/app/util/links.ts index eb343f39ae..3036ee9087 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -41,7 +41,7 @@ export const docLinks = { linkText: 'Anti-Affinity Groups', }, auditLog: { - href: 'https://docs.oxide.computer/guides/audit-log', + href: 'https://docs.oxide.computer/guides/operator/audit-log', linkText: 'Audit Logs', }, deviceTokens: { From 73e5c822d39c5d7eceb13e6f391630e391310c71 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 8 Jul 2026 17:27:19 -0500 Subject: [PATCH 57/75] audit logs -> audit log (there's only one) --- app/pages/system/AuditLog.tsx | 2 +- app/util/links.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index e7cfa62b2c..27f593fbdb 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -553,7 +553,7 @@ export default function SiloAuditLogsPage() { const selectedItem = expandedItem ? allItems[parseInt(expandedItem, 10)] : null - const errorMessage = error?.message ?? 'An error occurred while loading audit logs' + const errorMessage = error?.message ?? 'An error occurred while loading the audit log' const showError = error && !dismissedError // measure the list's distance from the top of the document so the window diff --git a/app/util/links.ts b/app/util/links.ts index 3036ee9087..480838871b 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -42,7 +42,7 @@ export const docLinks = { }, auditLog: { href: 'https://docs.oxide.computer/guides/operator/audit-log', - linkText: 'Audit Logs', + linkText: 'Audit Log', }, deviceTokens: { href: 'https://docs.oxide.computer/guides/working-with-api-and-sdk#_device_token_setup', From 8eef97e6ed058e659d1c73be690606ef6578ca80 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 20 Aug 2026 17:03:04 -0700 Subject: [PATCH 58/75] Fix up error messages The previous attempt to read an error message would never succeed, and since errors are generally happening at the bottom of the page, I moved the error message down there. If we get rid of interval refreshing, _all_ errors will be down there! --- app/pages/system/AuditLog.tsx | 38 +++++++++++++++-------------------- mock-api/msw/handlers.ts | 2 ++ 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 27f593fbdb..226a7d3c3c 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -350,7 +350,7 @@ const Row = memo(function Row({ export default function SiloAuditLogsPage() { const [expandedItem, setExpandedItem] = useState(null) - const [dismissedError, setDismissedError] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) // pass refetch interval to this to keep the date up to date const { preset, startTime, endTime, dateTimeRangePicker, onRangeChange } = @@ -380,7 +380,6 @@ export default function SiloAuditLogsPage() { isLoading, isPending, isFetching, - error, } = useInfiniteQuery({ queryKey: ['auditLogList', { query: queryParams }], queryFn: ({ pageParam }) => @@ -388,6 +387,11 @@ export default function SiloAuditLogsPage() { .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, @@ -397,7 +401,7 @@ export default function SiloAuditLogsPage() { // resetting the error if the query params change useEffect(() => { - setDismissedError(false) + setErrorMessage(null) }, [startTime, endTime, preset]) const allItems = useMemo(() => { @@ -531,6 +535,9 @@ export default function SiloAuditLogsPage() { /> ))}
+ {errorMessage !== null && ( + setErrorMessage(null)} /> + )}
{!hasNextPage && !isFetching && !isPending && allItems.length > 0 ? (
@@ -539,7 +546,10 @@ export default function SiloAuditLogsPage() { ) : (
@@ -641,7 +644,6 @@ const ExpandedItem = ({ totalCount, onNavigate, onClose, - hasError, }: { item: AuditLogEntry userId?: string @@ -650,7 +652,6 @@ const ExpandedItem = ({ totalCount: number onNavigate: (index: number) => void onClose: () => void - hasError: boolean }) => { // recomputing these on every parent re-render (e.g. on scroll) would be // wasted work — and would also defeat HighlightJSON's memo by passing a new @@ -659,14 +660,7 @@ const ExpandedItem = ({ const json = useMemo(() => JSON.stringify(snakeJson, null, 2), [snakeJson]) return ( -
+
From e9bd2e1d413ad674a52ca5153f1db1e3f5a44a29 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 27 Aug 2026 14:26:48 -0700 Subject: [PATCH 61/75] Fix Truncate type error from merging main --- app/pages/system/AuditLog.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index e37d70795f..0ac2fe8063 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -319,7 +319,7 @@ const Row = memo(function Row({
{userId ? ( - + ) : ( )} @@ -333,7 +333,7 @@ const Row = memo(function Row({
{siloId ? ( - + ) : ( )} @@ -702,7 +702,7 @@ const ExpandedItem = ({ {userId ? ( - + ) : ( )} @@ -718,7 +718,7 @@ const ExpandedItem = ({ {siloId ? ( - + ) : ( )} From 4db1a3e8ac083c4c851aa148f1347cd21b2cdab4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 16:14:51 -0500 Subject: [PATCH 62/75] Fix audit log docs popover summary --- app/pages/system/AuditLog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 0ac2fe8063..b9eaed8ccd 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -572,7 +572,7 @@ export default function SiloAuditLogsPage() { } - summary="The audit log provides a record of system activities, including user actions, API calls, and system events." + summary="The audit log records every API request that can modify the system. Read-only requests are not logged." links={[docLinks.auditLog]} /> From 41a02227c67880b6b1b05fd77c8e51b6d78d97f2 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 16:28:57 -0500 Subject: [PATCH 63/75] Show Retry instead of Load More after an audit log fetch error --- app/pages/system/AuditLog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index b9eaed8ccd..c94966ca0a 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -545,7 +545,7 @@ export default function SiloAuditLogsPage() { type="button" loading={isFetchingNextPage} > - Load More + {errorMessage !== null ? 'Retry' : 'Load More'} )}
From 2a0aaa4a8dffa670586c1b3873a747a235e02e36 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 16:36:54 -0500 Subject: [PATCH 64/75] j/k move between rows while the detail pane is open --- app/pages/system/AuditLog.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index c94966ca0a..e1928d1548 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -462,7 +462,8 @@ export default function SiloAuditLogsPage() { ?.focus({ preventScroll: true }) }, []) - // arrow keys move selection (and focus) between rows; escape closes the + // arrow keys (and j/k when the pane is open) move selection (and focus) + // between rows; escape closes the // modal. with the modal closed, arrows still move focus when a row is // focused, without opening the modal. adjacent rows are within the // virtualizer's overscan, so they're already in the DOM when we look them up. @@ -490,7 +491,15 @@ export default function SiloAuditLogsPage() { return } - const delta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0 + // 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 From e862967764f8ec56f0b726fb0e48ae116c7617ec Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 16:41:12 -0500 Subject: [PATCH 65/75] Audit log: yellow badge for 4xx status codes --- app/pages/system/AuditLog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index e1928d1548..c77a9d512d 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -226,7 +226,8 @@ const LoadingState = () => { } function StatusCodeCell({ code }: { code: number }) { - const color = code >= 200 && code < 500 ? 'default' : 'destructive' + // 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} } From 4f57769ceaf75aa0b10ed6db18740454af6f3a30 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 16:44:04 -0500 Subject: [PATCH 66/75] Audit log: align badges in detail pane header --- app/pages/system/AuditLog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index c77a9d512d..124b0ab485 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -680,7 +680,7 @@ const ExpandedItem = ({ > -

+

{item.operationId.split('_').join(' ')}

{match(item.result) From d8f95992744942b8a556ddf0b1fd6efd427bf8b0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 16:51:52 -0500 Subject: [PATCH 67/75] Resolve actor and silo names in audit log detail pane --- app/pages/system/AuditLog.tsx | 112 +++++++++++++++++++++++++++++++++- mock-api/audit-log.ts | 14 ++--- mock-api/msw/handlers.ts | 8 ++- test/e2e/audit-log.e2e.ts | 72 ++++++++++++++++++++++ 4 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 test/e2e/audit-log.e2e.ts diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 124b0ab485..5fa9846f6d 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import { getLocalTimeZone, now } from '@internationalized/date' -import { useInfiniteQuery } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' import { useWindowVirtualizer } from '@tanstack/react-virtual' import cn from 'classnames' import { differenceInMilliseconds } from 'date-fns' @@ -19,10 +19,16 @@ import { useRef, useState, } from 'react' +import { Link } from 'react-router' import { match, P } from 'ts-pattern' import { type JsonValue } from 'type-fest' -import { api, type AuditLogEntry, type AuditLogListQueryParams } from '@oxide/api' +import { + api, + qErrorsAllowed, + type AuditLogEntry, + type AuditLogListQueryParams, +} from '@oxide/api' import { Close12Icon, Error12Icon, @@ -46,6 +52,7 @@ 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' } @@ -225,6 +232,87 @@ const LoadingState = () => { ) } +// 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' @@ -667,6 +755,7 @@ const ExpandedItem = ({ type="button" onClick={() => currentIndex > 0 && onNavigate(currentIndex - 1)} disabled={currentIndex === 0} + aria-label="Previous entry" className="hover:bg-hover disabled:hover:bg-raise flex h-6 w-6 flex-shrink-0 rotate-90 items-center justify-center rounded disabled:cursor-default disabled:opacity-50" > {/* support arrow keys and keep centered autoscroll to element */} @@ -676,6 +765,7 @@ const ExpandedItem = ({ type="button" onClick={() => currentIndex < totalCount - 1 && onNavigate(currentIndex + 1)} disabled={currentIndex === totalCount - 1} + aria-label="Next entry" className="hover:bg-hover disabled:hover:bg-raise flex h-6 w-6 flex-shrink-0 rotate-90 items-center justify-center rounded disabled:cursor-default disabled:opacity-50" > @@ -693,6 +783,7 @@ const ExpandedItem = ({
+ + {match(item.actor) + .with({ kind: 'silo_user' }, (actor) => ( + + )) + .with({ kind: 'user_builtin' }, (actor) => ( + + )) + .with({ kind: 'scim' }, () => SCIM client) + .with({ kind: 'unauthenticated' }, () => ) + .exhaustive()} + + {userId ? ( @@ -726,6 +830,10 @@ const ExpandedItem = ({ )} + + {siloId ? : } + + {siloId ? ( diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts index ab1720e84c..83ce2dae73 100644 --- a/mock-api/audit-log.ts +++ b/mock-api/audit-log.ts @@ -13,19 +13,19 @@ import type { AuditLogEntry } from '@oxide/api' import { Rando } from '~/util/rando' import type { Json } from './json-type' -import { defaultSilo } from './silo' +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 = [ - 'a47ac10b-58cc-4372-a567-0e02b2c3d479', - '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + user1.id, + user2.id, 'c73bcdcc-2669-4bf6-81d3-e4ae73fb11fd', '550e8400-e29b-41d4-a716-446655440000', ] -const mockSiloIds = [ - 'f47ac10b-58cc-4372-a567-0e02b2c3d479', - '7ba7b810-9dad-11d1-80b4-00c04fd430c8', -] +const mockSiloIds = [myriadSilo.id, '7ba7b810-9dad-11d1-80b4-00c04fd430c8'] const mockOperations = [ 'instance_create', diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 7b0bd9530d..651f112ca8 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) @@ -2753,7 +2760,6 @@ export const handlers = makeHandlers({ siloPolicyUpdate: NotImplemented, siloPolicyView: NotImplemented, siloUserList: NotImplemented, - siloUserView: NotImplemented, sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, supportBundleCreate: NotImplemented, diff --git a/test/e2e/audit-log.e2e.ts b/test/e2e/audit-log.e2e.ts new file mode 100644 index 0000000000..c9681baea7 --- /dev/null +++ b/test/e2e/audit-log.e2e.ts @@ -0,0 +1,72 @@ +/* + * 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() + +// the detail pane is the only place with a Raw JSON heading +const pane = (page: Page) => + page + .getByRole('heading', { name: 'Raw JSON' }) + .locator('xpath=ancestor::div[contains(@class, "w-120")]') + +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() +}) From c8a66dcc92b0956417aa5a60dfebc04b41d1b68e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 17:11:54 -0500 Subject: [PATCH 68/75] Fetch 500 audit log entries per page --- app/pages/system/AuditLog.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 5fa9846f6d..b3f5e9bd39 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -450,6 +450,9 @@ export default function SiloAuditLogsPage() { startTime, endTime, sortBy: 'time_and_id_descending', + // 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 + limit: 500, } const { From 2584bbe7ec6f70cce92a1789cb2104a586188aea Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 16:57:02 -0500 Subject: [PATCH 69/75] Duration column: ms unit in header, formatted numbers --- app/pages/system/AuditLog.tsx | 20 +++++++++++++++----- app/ui/styles/components/audit-log.css | 5 +++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index b3f5e9bd39..77b8c2c281 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -215,7 +215,10 @@ const LoadingState = () => { {/* Duration column */}
-
+
))} @@ -327,11 +330,14 @@ const COLUMNS = [ { title: 'Actor ID', className: 'col-actor-id' }, { title: 'Auth Method', className: 'col-auth-method' }, { title: 'Silo ID', className: 'col-silo-id' }, - { title: 'Duration', className: 'col-duration' }, + { 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 = Intl.NumberFormat() + type RowProps = { log: AuditLogEntry index: number @@ -428,8 +434,9 @@ const Row = memo(function Row({ )}
- {differenceInMilliseconds(new Date(log.timeCompleted), log.timeStarted)} - ms + {msFormat.format( + differenceInMilliseconds(new Date(log.timeCompleted), log.timeStarted) + )}
@@ -846,7 +853,10 @@ const ExpandedItem = ({ - {differenceInMilliseconds(new Date(item.timeCompleted), item.timeStarted)}ms + {msFormat.format( + differenceInMilliseconds(new Date(item.timeCompleted), item.timeStarted) + )}{' '} + ms
diff --git a/app/ui/styles/components/audit-log.css b/app/ui/styles/components/audit-log.css index 19f09b0f61..51c922466c 100644 --- a/app/ui/styles/components/audit-log.css +++ b/app/ui/styles/components/audit-log.css @@ -36,6 +36,11 @@ .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 { From 8f23ec21fa6265a62e034782f90ef513a5bc1508 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 19:36:20 -0500 Subject: [PATCH 70/75] Audit log: show skeleton on time range change --- app/pages/system/AuditLog.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 77b8c2c281..29014138f8 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -486,12 +486,16 @@ export default function SiloAuditLogsPage() { }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextPage || undefined, - placeholderData: (x) => x, + // no placeholderData on purpose: a time range change should show the + // skeleton rather than the previous range's rows while the new one loads }) - // resetting the error if the query params change + // 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, preset]) const allItems = useMemo(() => { From 9ee76a299585341dac33fad7862b46ec309307b7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 19:38:31 -0500 Subject: [PATCH 71/75] Apply time range presets immediately, debounce only custom ranges --- .../form/fields/DateTimeRangePicker.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/app/components/form/fields/DateTimeRangePicker.tsx b/app/components/form/fields/DateTimeRangePicker.tsx index 49f1081bbe..05f2bc811a 100644 --- a/app/components/form/fields/DateTimeRangePicker.tsx +++ b/app/components/form/fields/DateTimeRangePicker.tsx @@ -84,15 +84,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, From 296d9031f314b3c2b98520ed3cca78059a7ea606 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 19:47:36 -0500 Subject: [PATCH 72/75] Audit log: stop paging after a short page, message for empty range --- app/pages/system/AuditLog.tsx | 32 +++++++++++++++++++++-------- mock-api/msw/util.spec.ts | 23 +++++++++++++-------- mock-api/msw/util.ts | 32 +++++++++++++---------------- test/e2e/audit-log.e2e.ts | 38 +++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 34 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 29014138f8..f368e6b184 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -338,6 +338,10 @@ const HeaderCell = classed.div`text-mono-sm text-tertiary` // shared so the virtualized rows don't each construct a formatter const msFormat = 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 @@ -457,9 +461,7 @@ export default function SiloAuditLogsPage() { startTime, endTime, sortBy: 'time_and_id_descending', - // 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 - limit: 500, + limit: PAGE_LIMIT, } const { @@ -485,7 +487,12 @@ export default function SiloAuditLogsPage() { throw result }), initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextPage || 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 }) @@ -616,6 +623,9 @@ export default function SiloAuditLogsPage() { return () => window.removeEventListener('keydown', onKeyDown) }, [expandedItem, allItems.length, handleToggle, focusRow, scrollToRow]) + // the list has everything it's going to get for this range + const settled = !hasNextPage && !isFetching && !isPending && errorMessage === null + const logTable = ( <>
setErrorMessage(null)} /> )} -
- {!hasNextPage && !isFetching && !isPending && allItems.length > 0 ? ( -
- No more logs to show within selected timeline +
+ {settled ? ( +
+ {allItems.length === 0 ? 'No logs' : 'No more logs'} in selected time range
) : (
- {!isLoading ? logTable : } + {loading ? : logTable}
From df8ac06f7a947c6e5c9d42001f8a5691268a7d5b Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 23:22:00 -0500 Subject: [PATCH 74/75] last few cleanups (naming, stale comments, a11y pane role, mock tidying) --- .../form/fields/DateTimeRangePicker.tsx | 4 +--- app/pages/system/AuditLog.tsx | 24 ++++++++++--------- mock-api/audit-log.ts | 14 +++++------ mock-api/msw/handlers.ts | 23 ++++++++---------- test/e2e/audit-log.e2e.ts | 6 +---- 5 files changed, 32 insertions(+), 39 deletions(-) diff --git a/app/components/form/fields/DateTimeRangePicker.tsx b/app/components/form/fields/DateTimeRangePicker.tsx index 05f2bc811a..8312e19cbb 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, diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 29026afb8a..23a340bec6 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -336,7 +336,7 @@ const COLUMNS = [ const HeaderCell = classed.div`text-mono-sm text-tertiary` // shared so the virtualized rows don't each construct a formatter -const msFormat = Intl.NumberFormat() +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 @@ -447,12 +447,11 @@ const Row = memo(function Row({ ) }) -export default function SiloAuditLogsPage() { +export default function AuditLogPage() { const [expandedItem, setExpandedItem] = useState(null) const [errorMessage, setErrorMessage] = useState(null) - // pass refetch interval to this to keep the date up to date - const { preset, startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ + const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ initialPreset: 'lastHour', maxValue: now(getLocalTimeZone()), }) @@ -496,7 +495,7 @@ export default function SiloAuditLogsPage() { setErrorMessage(null) setExpandedItem(null) window.scrollTo({ top: 0 }) - }, [startTime, endTime, preset]) + }, [startTime, endTime]) const allItems = useMemo(() => { return data?.pages.flatMap((page) => page.items) || [] @@ -573,10 +572,10 @@ export default function SiloAuditLogsPage() { }, []) // arrow keys (and j/k when the pane is open) move selection (and focus) - // between rows; escape closes the - // modal. with the modal closed, arrows still move focus when a row is - // focused, without opening the modal. adjacent rows are within the - // virtualizer's overscan, so they're already in the DOM when we look them up. + // 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 @@ -778,7 +777,10 @@ const ExpandedItem = ({ const json = useMemo(() => JSON.stringify(snakeJson, null, 2), [snakeJson]) return ( -
+
+ ) } diff --git a/mock-api/audit-log.ts b/mock-api/audit-log.ts index 83ce2dae73..18ca14782f 100644 --- a/mock-api/audit-log.ts +++ b/mock-api/audit-log.ts @@ -88,10 +88,10 @@ function generateAuditLogEntry(index: number): Json { 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 + 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 + completedTime.setMilliseconds(rando.next() * 300 + completedTime.getMilliseconds()) // deterministic random durations return { id: uuid(), @@ -132,7 +132,7 @@ export const auditLog: Json = [ 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 + 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', }, @@ -148,7 +148,7 @@ export const auditLog: Json = [ 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 + 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', @@ -171,7 +171,7 @@ export const auditLog: Json = [ 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 + 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', @@ -189,7 +189,7 @@ export const auditLog: Json = [ 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 + 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', }, @@ -206,7 +206,7 @@ export const auditLog: Json = [ 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 + 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', }, diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 651f112ca8..6d1afb8c64 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -2333,21 +2333,18 @@ export const handlers = makeHandlers({ ) return paginated(query, affinityGroups) }, - auditLogList: ({ query }) => { - let filteredLogs = db.auditLog - - if (query.pageToken === 'list-logs-500') throw internalError('list logs failed') + auditLogList: ({ query, cookies }) => { + requireFleetViewer(cookies) - if (query.startTime) { - filteredLogs = filteredLogs.filter( - (log) => new Date(log.time_completed) >= query.startTime! - ) + // 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 (query.endTime) { - filteredLogs = filteredLogs.filter( - (log) => new Date(log.time_completed) < query.endTime! - ) + if (endTime) { + filteredLogs = filteredLogs.filter((log) => new Date(log.time_completed) < endTime) } return paginated(query, filteredLogs) diff --git a/test/e2e/audit-log.e2e.ts b/test/e2e/audit-log.e2e.ts index 99e8eb1b1b..a30aeabf23 100644 --- a/test/e2e/audit-log.e2e.ts +++ b/test/e2e/audit-log.e2e.ts @@ -12,11 +12,7 @@ import { expect, test, type Page } from '@playwright/test' const row = (page: Page, operation: string) => page.getByRole('button', { name: new RegExp(operation, 'i') }).first() -// the detail pane is the only place with a Raw JSON heading -const pane = (page: Page) => - page - .getByRole('heading', { name: 'Raw JSON' }) - .locator('xpath=ancestor::div[contains(@class, "w-120")]') +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') From 702f56790eecb4b5906c16139e1e60ee8e2c1760 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 23:26:44 -0500 Subject: [PATCH 75/75] store expanded row index as a number --- app/pages/system/AuditLog.tsx | 50 ++++++++++++++--------------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/app/pages/system/AuditLog.tsx b/app/pages/system/AuditLog.tsx index 23a340bec6..655c7969b9 100644 --- a/app/pages/system/AuditLog.tsx +++ b/app/pages/system/AuditLog.tsx @@ -448,7 +448,7 @@ const Row = memo(function Row({ }) export default function AuditLogPage() { - const [expandedItem, setExpandedItem] = useState(null) + const [expandedItem, setExpandedItem] = useState(null) const [errorMessage, setErrorMessage] = useState(null) const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker({ @@ -520,17 +520,6 @@ export default function AuditLogPage() { scrollMargin, }) - const handleToggle = useCallback((index: string | null) => { - setExpandedItem(index) - }, []) - - // Row receives a stable callback that takes a number — keeps the memoized - // Row from re-rendering when its only changing prop would be the onClick - // closure - const selectRow = useCallback((index: number) => { - setExpandedItem(index.toString()) - }, []) - // scroll just enough to bring the row at `index` into the band between the // sticky header bottom and the viewport midpoint. only used for keyboard / // prev-next navigation — clicks intentionally leave scroll alone so the @@ -559,10 +548,10 @@ export default function AuditLogPage() { const navigateToIndex = useCallback( (newIndex: number) => { if (newIndex < 0 || newIndex >= allItems.length) return - handleToggle(newIndex.toString()) + setExpandedItem(newIndex) scrollToRow(newIndex) }, - [allItems.length, handleToggle, scrollToRow] + [allItems.length, scrollToRow] ) const focusRow = useCallback((index: number) => { @@ -584,17 +573,17 @@ export default function AuditLogPage() { // 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 = parseInt( + const currentIdx = expandedItem ?? - target?.closest('[data-row-index]')?.getAttribute('data-row-index') ?? - '', - 10 - ) + parseInt( + target?.closest('[data-row-index]')?.getAttribute('data-row-index') ?? '', + 10 + ) if (Number.isNaN(currentIdx)) return if (e.key === 'Escape' && expandedItem !== null) { e.preventDefault() - handleToggle(null) + setExpandedItem(null) // restore focus to the row in case focus was inside the modal focusRow(currentIdx) return @@ -614,13 +603,13 @@ export default function AuditLogPage() { 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) handleToggle(next.toString()) + if (expandedItem !== null) setExpandedItem(next) scrollToRow(next) focusRow(next) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [expandedItem, allItems.length, handleToggle, focusRow, scrollToRow]) + }, [expandedItem, allItems.length, focusRow, scrollToRow]) // the list has everything it's going to get for this range const settled = !hasNextPage && !isFetching && !isPending && errorMessage === null @@ -639,11 +628,12 @@ export default function AuditLogPage() { key={virtualRow.index} log={allItems[virtualRow.index]} index={virtualRow.index} - isExpanded={expandedItem === virtualRow.index.toString()} + isExpanded={expandedItem === virtualRow.index} size={virtualRow.size} start={virtualRow.start} scrollMargin={rowVirtualizer.options.scrollMargin} - onToggle={selectRow} + // setState is stable, so the memoized Row never re-renders for this prop + onToggle={setExpandedItem} /> ))}
@@ -679,7 +669,8 @@ export default function AuditLogPage() { ) - const selectedItem = expandedItem ? allItems[parseInt(expandedItem, 10)] : null + // 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 @@ -719,7 +710,8 @@ export default function AuditLogPage() { ))}
- {selectedItem && + {expandedItem !== null && + selectedItem && (() => { const [userId, siloId] = match(selectedItem.actor) .with({ kind: 'silo_user' }, (actor) => [actor.siloUserId, actor.siloId]) @@ -731,17 +723,15 @@ export default function AuditLogPage() { .with({ kind: 'unauthenticated' }, () => [undefined, undefined]) .exhaustive() - const currentIndex = parseInt(expandedItem!, 10) - return ( handleToggle(null)} + onClose={() => setExpandedItem(null)} /> ) })()}