From 0c49f074a5a9cdd3ce7360ba3392a0c674ef67a8 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:05:53 +0100 Subject: [PATCH 01/26] 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 f18fa05d6..af4b8a89c 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 ba3609b9b..1cc95a7c1 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 907cbd5f8..8f5724904 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 096491ef6..7e6841314 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 3c54af9f8..9b7ba859b 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/26] 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 361727119..d97687ff4 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 ae08a2e69..3c36bb390 100644 --- a/app/layouts/helpers.tsx +++ b/app/layouts/helpers.tsx @@ -28,7 +28,7 @@ export function ContentPane() { >
-
+
diff --git a/app/pages/SiloAuditLogsPage.tsx b/app/pages/SiloAuditLogsPage.tsx new file mode 100644 index 000000000..d9fdbe1df --- /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 f653b18af..0ddb3672f 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 9f504267d..81aa17e16 100644 --- a/app/util/date.ts +++ b/app/util/date.ts @@ -53,3 +53,19 @@ export const toLocaleTimeString = (d: Date, locale?: string) => export const toLocaleDateTimeString = (d: Date, locale?: string) => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d) + +// `Jan 21` +export const toSyslogDateString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + }).format(d) + +// `23:33:45` +export const toSyslogTimeString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(d) diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 1a75b7354..ca612806c 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 000000000..46fc0f72b --- /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 ed6851294..a2593fb11 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -7,6 +7,7 @@ */ export * from './affinity-group' +export * from './audit-log' export * from './disk' export * from './external-ip' export * from './floating-ip' diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index cecc37e66..cfa07af05 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 c868aa41e..32eeeabbf 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/26] 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 ae50282ff..e4ab8ac3a 100644 --- a/app/ui/lib/DatePicker.tsx +++ b/app/ui/lib/DatePicker.tsx @@ -55,7 +55,7 @@ export function DatePicker(props: DatePickerProps) { type="button" className={cn( state.isOpen && 'z-10 ring-2', - 'relative flex h-11 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise focus:z-10', + 'relative flex h-10 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise focus:z-10', state.isInvalid ? 'focus-error border-error ring-error-secondary' : 'border-default ring-accent-secondary' diff --git a/app/ui/lib/DateRangePicker.tsx b/app/ui/lib/DateRangePicker.tsx index ff7e2c71c..0f696e30d 100644 --- a/app/ui/lib/DateRangePicker.tsx +++ b/app/ui/lib/DateRangePicker.tsx @@ -63,7 +63,7 @@ export function DateRangePicker(props: DateRangePickerProps) { type="button" className={cn( state.isOpen && 'z-10 ring-2', - 'relative flex h-11 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise focus:z-10', + 'relative flex h-10 items-center rounded-l rounded-r border text-sans-md border-default focus-within:ring-2 hover:border-raise focus:z-10', state.isInvalid ? 'focus-error border-error ring-error-secondary hover:border-error' : 'border-default ring-accent-secondary' diff --git a/app/ui/lib/Listbox.tsx b/app/ui/lib/Listbox.tsx index 71aef4a94..b6be2bc60 100644 --- a/app/ui/lib/Listbox.tsx +++ b/app/ui/lib/Listbox.tsx @@ -101,7 +101,7 @@ export const Listbox = ({ id={id} name={name} className={cn( - `flex h-11 items-center justify-between rounded border text-sans-md`, + `flex h-10 items-center justify-between rounded border text-sans-md`, hasError ? 'focus-error border-error-secondary hover:border-error' : 'border-default hover:border-hover', diff --git a/app/ui/lib/Table.tsx b/app/ui/lib/Table.tsx index b48d13771..27e3a0465 100644 --- a/app/ui/lib/Table.tsx +++ b/app/ui/lib/Table.tsx @@ -105,7 +105,7 @@ Table.Cell = ({ height = 'small', className, children, ...props }: TableCellProp
{children} From fdb45063ee02e215432aecc8647ba5808c16be47 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 8 Jul 2025 11:08:05 +0100 Subject: [PATCH 04/26] 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 913f9c6f1..29c2245ee 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/26] 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 af4b8a89c..fee9b1e46 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 1cc95a7c1..91762746f 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 8f5724904..749d036c2 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 7e6841314..8066a72df 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 9b7ba859b..3a5ff5d72 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/26] 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 46fc0f72b..394943c95 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 32eeeabbf..42ab43d8e 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/26] 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 d97687ff4..361727119 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 69ff5828e..9f15dc332 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -12,6 +12,7 @@ import { apiQueryClient } from '@oxide/api' import { Cloud16Icon, IpGlobal16Icon, + Logs16Icon, Metrics16Icon, Servers16Icon, } from '@oxide/design-system/icons/react' @@ -63,6 +64,7 @@ export default function SystemLayout() { { value: 'Utilization', path: pb.systemUtilization() }, { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, + { value: 'Audit Log', path: pb.auditLog() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -106,6 +108,9 @@ export default function SystemLayout() { IP Pools + + Audit Log + diff --git a/app/pages/SiloAuditLogsPage.tsx b/app/pages/SiloAuditLogsPage.tsx index 8c3db297d..1414c31a4 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 0ddb3672f..7e371f3e7 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 ca612806c..2217b54cb 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/26] 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 2393aa324..d1703a680 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -76,6 +76,12 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/affinity/aag", }, ], + "auditLog (/system/audit-log)": [ + { + "label": "Audit Log", + "path": "/system/audit-log", + }, + ], "deviceSuccess (/device/success)": [], "diskInventory (/system/inventory/disks)": [ { diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 202994c02..c1c4fa264 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -46,6 +46,7 @@ test('path builder', () => { "affinityNew": "/projects/p/affinity-new", "antiAffinityGroup": "/projects/p/affinity/aag", "antiAffinityGroupEdit": "/projects/p/affinity/aag/edit", + "auditLog": "/system/audit-log", "deviceSuccess": "/device/success", "diskInventory": "/system/inventory/disks", "disks": "/projects/p/disks", From c6f5d9678cc884ff64b47e21806f24f490815c5d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 4 Aug 2025 21:36:55 -0500 Subject: [PATCH 09/26] 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 1414c31a4..fc3d0a0a0 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 7e371f3e7..0b4c2a7b8 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/26] 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 fc3d0a0a0..b5f5cef91 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/26] 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 371104ac5..fca138998 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/26] 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 b5f5cef91..1fa0c6ea5 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 29c2245ee..e2fb02ea8 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 fca138998..7f8204729 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 329b9366d..630e83e96 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 5be5c641f..e5dc831c5 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/26] 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 1fa0c6ea5..f1b202755 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/26] 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 f1b202755..14490bbec 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/26] 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 7f8204729..859e1c254 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/26] 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 14490bbec..f35ae575d 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/26] 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 f35ae575d..f8b6e2087 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/26] 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 f8b6e2087..66e9f3db3 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/26] 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 66e9f3db3..5e8a523ae 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/26] 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 1824a76ea..2ba07213b 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/26] 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 2ba07213b..b754ea5e8 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/26] 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 b754ea5e8..8ffb45fca 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/26] 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 8ffb45fca..13dbdba36 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 f00b2523a..733084276 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 6487313e5..3b730f5bb 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -104,3 +104,10 @@ export function diskSizeNearest10(imageSizeGiB: number) { const nearest10 = Math.ceil(imageSizeGiB / 10) * 10 return Math.min(nearest10, MAX_DISK_SIZE_GiB) } + +export function deterRandom(i: number, target: number, range: number) { + const variation = + (Math.sin(i * 0.7) * 1.0 + Math.sin(i * 1.3) * 0.75 + Math.sin(i * 2.1) * 0.5) / 2.25 // Normalize to approximately [-1, 1] + + return target + variation * range +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 967c8f030..dd2768389 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -69,10 +69,13 @@ export default { pulse2: 'pulse2 1.3s cubic-bezier(.4,0,.6,1) infinite', }, keyframes: { + pulse: { + '50%': { opacity: '0.66' }, + }, // different from pulse in that we go up a little before we go back down. // pulse starts at opacity 1 pulse2: { - '0%, 100%': { opacity: '0.75' }, + '0%, 100%': { opacity: '0.66' }, '50%': { opacity: '1' }, }, }, From 42fbfd0a8266dc4b22a80716bd56eb1f71c66a4f Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Mon, 22 Sep 2025 16:44:19 +0100 Subject: [PATCH 24/26] 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 13dbdba36..aa50973ae 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 ( -
+