diff --git a/.github/workflows/lintBuildTest.yml b/.github/workflows/lintBuildTest.yml index 55af5bc78c..9d3c4fbcd8 100644 --- a/.github/workflows/lintBuildTest.yml +++ b/.github/workflows/lintBuildTest.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Cache node_modules uses: actions/cache@v4 @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Get node_modules from cache uses: actions/cache@v4 @@ -61,7 +61,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Get node_modules from cache uses: actions/cache@v4 diff --git a/.github/workflows/reformatter.yaml b/.github/workflows/reformatter.yaml index c163c4d522..15004f92c7 100644 --- a/.github/workflows/reformatter.yaml +++ b/.github/workflows/reformatter.yaml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Install dependencies diff --git a/.github/workflows/upload-assets.yaml b/.github/workflows/upload-assets.yaml index 498ded32f4..1c275dcd09 100644 --- a/.github/workflows/upload-assets.yaml +++ b/.github/workflows/upload-assets.yaml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: 'Authenticate to Google Cloud' uses: 'google-github-actions/auth@v2' diff --git a/OMICRON_VERSION b/OMICRON_VERSION index f7a1d4ae62..81a3894e7b 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -c50cf019cd9be35f98266a7f4acacab0236b3a3d +d4263cb47512582f245b95e6dd046e84416baf2d diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index e64990f1f9..02a553344c 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -1710,6 +1710,16 @@ export type ImageResultsPage = { */ export type ImportBlocksBulkWrite = { base64EncodedData: string; offset: number } +/** + * A policy determining when an instance should be automatically restarted by the control plane. + */ +export type InstanceAutoRestartPolicy = + /** The instance should not be automatically restarted by the control plane if it fails. */ + | 'never' + + /** If this instance is running and unexpectedly fails (e.g. due to a host software crash or unexpected host reboot), the control plane will make a best-effort attempt to restart it. The control plane may choose not to restart the instance to preserve the overall availability of the system. */ + | 'best_effort' + /** * The number of CPUs in an Instance */ @@ -1761,6 +1771,10 @@ If this is not present, then either the instance has never been automatically re autoRestartCooldownExpiration?: Date /** `true` if this instance's auto-restart policy will permit the control plane to automatically restart it if it enters the `Failed` state. */ autoRestartEnabled: boolean + /** The auto-restart policy configured for this instance, or `None` if no explicit policy is configured. + +If this is not present, then this instance uses the default auto-restart policy, which may or may not allow it to be restarted. The `auto_restart_enabled` field indicates whether the instance will be automatically restarted. */ + autoRestartPolicy?: InstanceAutoRestartPolicy /** the ID of the disk used to boot this Instance, if a specific one is assigned. */ bootDiskId?: string /** human-readable free-form text about a resource */ @@ -1789,16 +1803,6 @@ If this is not present, then this instance has not been automatically restarted. timeRunStateUpdated: Date } -/** - * A policy determining when an instance should be automatically restarted by the control plane. - */ -export type InstanceAutoRestartPolicy = - /** The instance should not be automatically restarted by the control plane if it fails. */ - | 'never' - - /** If this instance is running and unexpectedly fails (e.g. due to a host software crash or unexpected host reboot), the control plane will make a best-effort attempt to restart it. The control plane may choose not to restart the instance to preserve the overall availability of the system. */ - | 'best_effort' - /** * Describe the instance's disks at creation time */ @@ -1976,12 +1980,127 @@ export type InstanceSerialConsoleData = { * Parameters of an `Instance` that can be reconfigured after creation. */ export type InstanceUpdate = { + /** The auto-restart policy for this instance. + +If not provided, unset the instance's auto-restart policy. */ + autoRestartPolicy?: InstanceAutoRestartPolicy /** Name or ID of the disk the instance should be instructed to boot from. If not provided, unset the instance's boot disk. */ bootDisk?: NameOrId } +/** + * An internet gateway provides a path between VPC networks and external networks. + */ +export type InternetGateway = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date + /** The VPC to which the gateway belongs. */ + vpcId: string +} + +/** + * Create-time parameters for an `InternetGateway` + */ +export type InternetGatewayCreate = { description: string; name: Name } + +/** + * An IP address that is attached to an internet gateway + */ +export type InternetGatewayIpAddress = { + /** The associated IP address, */ + address: string + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** The associated internet gateway. */ + internetGatewayId: string + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date +} + +/** + * Create-time identity-related parameters + */ +export type InternetGatewayIpAddressCreate = { + address: string + description: string + name: Name +} + +/** + * A single page of results + */ +export type InternetGatewayIpAddressResultsPage = { + /** list of items on this page of results */ + items: InternetGatewayIpAddress[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string +} + +/** + * An IP pool that is attached to an internet gateway + */ +export type InternetGatewayIpPool = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** The associated internet gateway. */ + internetGatewayId: string + /** The associated IP pool. */ + ipPoolId: string + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date +} + +/** + * Create-time identity-related parameters + */ +export type InternetGatewayIpPoolCreate = { + description: string + ipPool: NameOrId + name: Name +} + +/** + * A single page of results + */ +export type InternetGatewayIpPoolResultsPage = { + /** list of items on this page of results */ + items: InternetGatewayIpPool[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string +} + +/** + * A single page of results + */ +export type InternetGatewayResultsPage = { + /** list of items on this page of results */ + items: InternetGateway[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string +} + /** * A collection of IP ranges. If a pool is linked to a silo, IP addresses from the pool can be allocated within that silo */ @@ -2610,7 +2729,7 @@ export type Route = { /** The route gateway. */ gw: string /** Local preference for route. Higher preference indictes precedence within and across protocols. */ - localPref?: number + ribPriority?: number /** VLAN id the gateway is reachable over. */ vid?: number } @@ -2687,7 +2806,7 @@ export type RouterRouteKind = export type RouterRoute = { /** human-readable free-form text about a resource */ description: string - /** Selects which traffic this routing rule will apply to. */ + /** Selects which traffic this routing rule will apply to */ destination: RouteDestination /** unique, immutable, system-controlled identifier for each resource */ id: string @@ -2695,7 +2814,7 @@ export type RouterRoute = { kind: RouterRouteKind /** unique, mutable, user-controlled identifier for each resource */ name: Name - /** The location that matched packets should be forwarded to. */ + /** The location that matched packets should be forwarded to */ target: RouteTarget /** timestamp when this resource was created */ timeCreated: Date @@ -3397,10 +3516,10 @@ export type SwitchPortRouteConfig = { gw: IpNet /** The interface name this route configuration is assigned to. */ interfaceName: string - /** Local preference indicating priority within and across protocols. */ - localPref?: number /** The port settings object this route configuration belongs to. */ portSettingsId: string + /** RIB Priority indicating priority within and across protocols. */ + ribPriority?: number /** The VLAN identifier for the route. Use this if the gateway is reachable over an 802.1Q tagged L2 segment. */ vlanId?: number } @@ -4363,6 +4482,90 @@ export interface InstanceStopQueryParams { project?: NameOrId } +export interface InternetGatewayIpAddressListQueryParams { + gateway?: NameOrId + limit?: number + pageToken?: string + project?: NameOrId + sortBy?: NameOrIdSortMode + vpc?: NameOrId +} + +export interface InternetGatewayIpAddressCreateQueryParams { + gateway: NameOrId + project?: NameOrId + vpc?: NameOrId +} + +export interface InternetGatewayIpAddressDeletePathParams { + address: NameOrId +} + +export interface InternetGatewayIpAddressDeleteQueryParams { + cascade?: boolean + gateway?: NameOrId + project?: NameOrId + vpc?: NameOrId +} + +export interface InternetGatewayIpPoolListQueryParams { + gateway?: NameOrId + limit?: number + pageToken?: string + project?: NameOrId + sortBy?: NameOrIdSortMode + vpc?: NameOrId +} + +export interface InternetGatewayIpPoolCreateQueryParams { + gateway: NameOrId + project?: NameOrId + vpc?: NameOrId +} + +export interface InternetGatewayIpPoolDeletePathParams { + pool: NameOrId +} + +export interface InternetGatewayIpPoolDeleteQueryParams { + cascade?: boolean + gateway?: NameOrId + project?: NameOrId + vpc?: NameOrId +} + +export interface InternetGatewayListQueryParams { + limit?: number + pageToken?: string + project?: NameOrId + sortBy?: NameOrIdSortMode + vpc?: NameOrId +} + +export interface InternetGatewayCreateQueryParams { + project?: NameOrId + vpc: NameOrId +} + +export interface InternetGatewayViewPathParams { + gateway: NameOrId +} + +export interface InternetGatewayViewQueryParams { + project?: NameOrId + vpc?: NameOrId +} + +export interface InternetGatewayDeletePathParams { + gateway: NameOrId +} + +export interface InternetGatewayDeleteQueryParams { + cascade?: boolean + project?: NameOrId + vpc?: NameOrId +} + export interface ProjectIpPoolListQueryParams { limit?: number pageToken?: string @@ -5103,6 +5306,9 @@ export type ApiListMethods = Pick< | 'instanceDiskList' | 'instanceExternalIpList' | 'instanceSshPublicKeyList' + | 'internetGatewayIpAddressList' + | 'internetGatewayIpPoolList' + | 'internetGatewayList' | 'projectIpPoolList' | 'currentUserSshKeyList' | 'instanceNetworkInterfaceList' @@ -5998,6 +6204,185 @@ export class Api extends HttpClient { ...params, }) }, + /** + * List IP addresses attached to internet gateway + */ + internetGatewayIpAddressList: ( + { query = {} }: { query?: InternetGatewayIpAddressListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-addresses`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Attach IP address to internet gateway + */ + internetGatewayIpAddressCreate: ( + { + query, + body, + }: { + query: InternetGatewayIpAddressCreateQueryParams + body: InternetGatewayIpAddressCreate + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-addresses`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Detach IP address from internet gateway + */ + internetGatewayIpAddressDelete: ( + { + path, + query = {}, + }: { + path: InternetGatewayIpAddressDeletePathParams + query?: InternetGatewayIpAddressDeleteQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-addresses/${path.address}`, + method: 'DELETE', + query, + ...params, + }) + }, + /** + * List IP pools attached to internet gateway + */ + internetGatewayIpPoolList: ( + { query = {} }: { query?: InternetGatewayIpPoolListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-pools`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Attach IP pool to internet gateway + */ + internetGatewayIpPoolCreate: ( + { + query, + body, + }: { + query: InternetGatewayIpPoolCreateQueryParams + body: InternetGatewayIpPoolCreate + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-pools`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Detach IP pool from internet gateway + */ + internetGatewayIpPoolDelete: ( + { + path, + query = {}, + }: { + path: InternetGatewayIpPoolDeletePathParams + query?: InternetGatewayIpPoolDeleteQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-pools/${path.pool}`, + method: 'DELETE', + query, + ...params, + }) + }, + /** + * List internet gateways + */ + internetGatewayList: ( + { query = {} }: { query?: InternetGatewayListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateways`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Create VPC internet gateway + */ + internetGatewayCreate: ( + { + query, + body, + }: { query: InternetGatewayCreateQueryParams; body: InternetGatewayCreate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateways`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Fetch internet gateway + */ + internetGatewayView: ( + { + path, + query = {}, + }: { path: InternetGatewayViewPathParams; query?: InternetGatewayViewQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateways/${path.gateway}`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Delete internet gateway + */ + internetGatewayDelete: ( + { + path, + query = {}, + }: { + path: InternetGatewayDeletePathParams + query?: InternetGatewayDeleteQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateways/${path.gateway}`, + method: 'DELETE', + query, + ...params, + }) + }, /** * List IP pools */ diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 2c1de3ebde..a09d6797e3 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 -c50cf019cd9be35f98266a7f4acacab0236b3a3d +d4263cb47512582f245b95e6dd046e84416baf2d diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 49df51a418..0e455163ac 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -407,6 +407,73 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/internet-gateway-ip-addresses` */ + internetGatewayIpAddressList: (params: { + query: Api.InternetGatewayIpAddressListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/internet-gateway-ip-addresses` */ + internetGatewayIpAddressCreate: (params: { + query: Api.InternetGatewayIpAddressCreateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/internet-gateway-ip-addresses/:address` */ + internetGatewayIpAddressDelete: (params: { + path: Api.InternetGatewayIpAddressDeletePathParams + query: Api.InternetGatewayIpAddressDeleteQueryParams + req: Request + cookies: Record + }) => Promisable + /** `GET /v1/internet-gateway-ip-pools` */ + internetGatewayIpPoolList: (params: { + query: Api.InternetGatewayIpPoolListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/internet-gateway-ip-pools` */ + internetGatewayIpPoolCreate: (params: { + query: Api.InternetGatewayIpPoolCreateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/internet-gateway-ip-pools/:pool` */ + internetGatewayIpPoolDelete: (params: { + path: Api.InternetGatewayIpPoolDeletePathParams + query: Api.InternetGatewayIpPoolDeleteQueryParams + req: Request + cookies: Record + }) => Promisable + /** `GET /v1/internet-gateways` */ + internetGatewayList: (params: { + query: Api.InternetGatewayListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/internet-gateways` */ + internetGatewayCreate: (params: { + query: Api.InternetGatewayCreateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/internet-gateways/:gateway` */ + internetGatewayView: (params: { + path: Api.InternetGatewayViewPathParams + query: Api.InternetGatewayViewQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/internet-gateways/:gateway` */ + internetGatewayDelete: (params: { + path: Api.InternetGatewayDeletePathParams + query: Api.InternetGatewayDeleteQueryParams + req: Request + cookies: Record + }) => Promisable /** `GET /v1/ip-pools` */ projectIpPoolList: (params: { query: Api.ProjectIpPoolListQueryParams @@ -1685,6 +1752,74 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/instances/:instance/stop', handler(handlers['instanceStop'], schema.InstanceStopParams, null) ), + http.get( + '/v1/internet-gateway-ip-addresses', + handler( + handlers['internetGatewayIpAddressList'], + schema.InternetGatewayIpAddressListParams, + null + ) + ), + http.post( + '/v1/internet-gateway-ip-addresses', + handler( + handlers['internetGatewayIpAddressCreate'], + schema.InternetGatewayIpAddressCreateParams, + schema.InternetGatewayIpAddressCreate + ) + ), + http.delete( + '/v1/internet-gateway-ip-addresses/:address', + handler( + handlers['internetGatewayIpAddressDelete'], + schema.InternetGatewayIpAddressDeleteParams, + null + ) + ), + http.get( + '/v1/internet-gateway-ip-pools', + handler( + handlers['internetGatewayIpPoolList'], + schema.InternetGatewayIpPoolListParams, + null + ) + ), + http.post( + '/v1/internet-gateway-ip-pools', + handler( + handlers['internetGatewayIpPoolCreate'], + schema.InternetGatewayIpPoolCreateParams, + schema.InternetGatewayIpPoolCreate + ) + ), + http.delete( + '/v1/internet-gateway-ip-pools/:pool', + handler( + handlers['internetGatewayIpPoolDelete'], + schema.InternetGatewayIpPoolDeleteParams, + null + ) + ), + http.get( + '/v1/internet-gateways', + handler(handlers['internetGatewayList'], schema.InternetGatewayListParams, null) + ), + http.post( + '/v1/internet-gateways', + handler( + handlers['internetGatewayCreate'], + schema.InternetGatewayCreateParams, + schema.InternetGatewayCreate + ) + ), + http.get( + '/v1/internet-gateways/:gateway', + handler(handlers['internetGatewayView'], schema.InternetGatewayViewParams, null) + ), + http.delete( + '/v1/internet-gateways/:gateway', + handler(handlers['internetGatewayDelete'], schema.InternetGatewayDeleteParams, null) + ), http.get( '/v1/ip-pools', handler(handlers['projectIpPoolList'], schema.ProjectIpPoolListParams, null) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 77402ef0df..1fea712f65 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -1645,6 +1645,14 @@ export const ImportBlocksBulkWrite = z.preprocess( z.object({ base64EncodedData: z.string(), offset: z.number().min(0) }) ) +/** + * A policy determining when an instance should be automatically restarted by the control plane. + */ +export const InstanceAutoRestartPolicy = z.preprocess( + processResponseBody, + z.enum(['never', 'best_effort']) +) + /** * The number of CPUs in an Instance */ @@ -1682,6 +1690,7 @@ export const Instance = z.preprocess( z.object({ autoRestartCooldownExpiration: z.coerce.date().optional(), autoRestartEnabled: SafeBoolean, + autoRestartPolicy: InstanceAutoRestartPolicy.optional(), bootDiskId: z.string().uuid().optional(), description: z.string(), hostname: z.string(), @@ -1698,14 +1707,6 @@ export const Instance = z.preprocess( }) ) -/** - * A policy determining when an instance should be automatically restarted by the control plane. - */ -export const InstanceAutoRestartPolicy = z.preprocess( - processResponseBody, - z.enum(['never', 'best_effort']) -) - /** * Describe the instance's disks at creation time */ @@ -1852,7 +1853,105 @@ export const InstanceSerialConsoleData = z.preprocess( */ export const InstanceUpdate = z.preprocess( processResponseBody, - z.object({ bootDisk: NameOrId.optional() }) + z.object({ + autoRestartPolicy: InstanceAutoRestartPolicy.optional(), + bootDisk: NameOrId.optional(), + }) +) + +/** + * An internet gateway provides a path between VPC networks and external networks. + */ +export const InternetGateway = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.string().uuid(), + name: Name, + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + vpcId: z.string().uuid(), + }) +) + +/** + * Create-time parameters for an `InternetGateway` + */ +export const InternetGatewayCreate = z.preprocess( + processResponseBody, + z.object({ description: z.string(), name: Name }) +) + +/** + * An IP address that is attached to an internet gateway + */ +export const InternetGatewayIpAddress = z.preprocess( + processResponseBody, + z.object({ + address: z.string().ip(), + description: z.string(), + id: z.string().uuid(), + internetGatewayId: z.string().uuid(), + name: Name, + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + }) +) + +/** + * Create-time identity-related parameters + */ +export const InternetGatewayIpAddressCreate = z.preprocess( + processResponseBody, + z.object({ address: z.string().ip(), description: z.string(), name: Name }) +) + +/** + * A single page of results + */ +export const InternetGatewayIpAddressResultsPage = z.preprocess( + processResponseBody, + z.object({ items: InternetGatewayIpAddress.array(), nextPage: z.string().optional() }) +) + +/** + * An IP pool that is attached to an internet gateway + */ +export const InternetGatewayIpPool = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.string().uuid(), + internetGatewayId: z.string().uuid(), + ipPoolId: z.string().uuid(), + name: Name, + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + }) +) + +/** + * Create-time identity-related parameters + */ +export const InternetGatewayIpPoolCreate = z.preprocess( + processResponseBody, + z.object({ description: z.string(), ipPool: NameOrId, name: Name }) +) + +/** + * A single page of results + */ +export const InternetGatewayIpPoolResultsPage = z.preprocess( + processResponseBody, + z.object({ items: InternetGatewayIpPool.array(), nextPage: z.string().optional() }) +) + +/** + * A single page of results + */ +export const InternetGatewayResultsPage = z.preprocess( + processResponseBody, + z.object({ items: InternetGateway.array(), nextPage: z.string().optional() }) ) /** @@ -2483,7 +2582,7 @@ export const Route = z.preprocess( z.object({ dst: IpNet, gw: z.string().ip(), - localPref: z.number().min(0).max(4294967295).optional(), + ribPriority: z.number().min(0).max(255).optional(), vid: z.number().min(0).max(65535).optional(), }) ) @@ -3138,8 +3237,8 @@ export const SwitchPortRouteConfig = z.preprocess( dst: IpNet, gw: IpNet, interfaceName: z.string(), - localPref: z.number().min(0).max(4294967295).optional(), portSettingsId: z.string().uuid(), + ribPriority: z.number().min(0).max(255).optional(), vlanId: z.number().min(0).max(65535).optional(), }) ) @@ -4348,6 +4447,142 @@ export const InstanceStopParams = z.preprocess( }) ) +export const InternetGatewayIpAddressListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + gateway: NameOrId.optional(), + limit: z.number().min(1).max(4294967295).optional(), + pageToken: z.string().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayIpAddressCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + gateway: NameOrId, + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayIpAddressDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + address: NameOrId, + }), + query: z.object({ + cascade: SafeBoolean.optional(), + gateway: NameOrId.optional(), + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayIpPoolListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + gateway: NameOrId.optional(), + limit: z.number().min(1).max(4294967295).optional(), + pageToken: z.string().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayIpPoolCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + gateway: NameOrId, + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayIpPoolDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({ + cascade: SafeBoolean.optional(), + gateway: NameOrId.optional(), + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.number().min(1).max(4294967295).optional(), + pageToken: z.string().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + project: NameOrId.optional(), + vpc: NameOrId, + }), + }) +) + +export const InternetGatewayViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + gateway: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + gateway: NameOrId, + }), + query: z.object({ + cascade: SafeBoolean.optional(), + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + export const ProjectIpPoolListParams = z.preprocess( processResponseBody, z.object({ diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 878021a11f..25177c0dde 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -11,12 +11,14 @@ import { useForm } from 'react-hook-form' import { useApiMutation, useApiQueryClient, usePrefetchedApiQuery } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Badge } from '~/ui/lib/Badge' import { Modal } from '~/ui/lib/Modal' import { ALL_ISH } from '~/util/consts' +import { toIpPoolItem } from './form/fields/ip-pool-item' + export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) => { const queryClient = useApiQueryClient() const { project, instance } = useInstanceSelector() @@ -28,9 +30,9 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) [siloPools] ) const instanceEphemeralIpAttach = useApiMutation('instanceEphemeralIpAttach', { - onSuccess() { + onSuccess(ephemeralIp) { queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your ephemeral IP has been attached' }) + addToast(<>IP {ephemeralIp.ip} attached) // prettier-ignore onDismiss() }, onError: (err) => { @@ -54,17 +56,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) ? 'Select a pool' : 'No pools available' } - items={ - siloPools?.items.map((pool) => ({ - label: ( -
- {pool.name} - {pool.isDefault && default} -
- ), - value: pool.name, - })) || [] - } + items={siloPools.items.map(toIpPoolItem)} required /> diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx index eaedd2dbdc..cc351bde18 100644 --- a/app/components/AttachFloatingIpModal.tsx +++ b/app/components/AttachFloatingIpModal.tsx @@ -10,6 +10,7 @@ import { useForm } from 'react-hook-form' import { useApiMutation, useApiQueryClient, type FloatingIp, type Instance } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' @@ -45,10 +46,10 @@ export const AttachFloatingIpModal = ({ }) => { const queryClient = useApiQueryClient() const floatingIpAttach = useApiMutation('floatingIpAttach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your floating IP has been attached' }) + addToast(<>IP {floatingIp.name} attached) // prettier-ignore onDismiss() }, onError: (err) => { diff --git a/app/components/DocsPopover.tsx b/app/components/DocsPopover.tsx index b393f167c5..76ddbcc791 100644 --- a/app/components/DocsPopover.tsx +++ b/app/components/DocsPopover.tsx @@ -9,7 +9,7 @@ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react' import cn from 'classnames' -import { OpenLink12Icon, Question12Icon } from '@oxide/design-system/icons/react' +import { Info16Icon, OpenLink12Icon } from '@oxide/design-system/icons/react' import { buttonStyle } from '~/ui/lib/Button' @@ -45,7 +45,7 @@ export const DocsPopover = ({ heading, icon, summary, links }: DocsPopoverProps) return ( - + +
{transition((style, item) => ( - + Settings logout.mutate({})}> Sign out diff --git a/app/components/TopBarPicker.tsx b/app/components/TopBarPicker.tsx index 17da2c90a5..5ae3432948 100644 --- a/app/components/TopBarPicker.tsx +++ b/app/components/TopBarPicker.tsx @@ -112,7 +112,7 @@ const TopBarPicker = (props: TopBarPickerProps) => { {/* TODO: popover position should be further right */} {props.items && ( {props.items.length > 0 ? ( diff --git a/app/components/form/fields/ImageSelectField.tsx b/app/components/form/fields/ImageSelectField.tsx index 2254c0cd00..0f2307c063 100644 --- a/app/components/form/fields/ImageSelectField.tsx +++ b/app/components/form/fields/ImageSelectField.tsx @@ -80,10 +80,10 @@ export function toImageComboboxItem( value: id, selectedLabel: name, label: ( - <> +
{name}
{itemMetadata}
- +
), } } diff --git a/app/components/form/fields/NameField.tsx b/app/components/form/fields/NameField.tsx index a8fa6d1e16..7f3100c978 100644 --- a/app/components/form/fields/NameField.tsx +++ b/app/components/form/fields/NameField.tsx @@ -30,6 +30,17 @@ export function NameField< required={required} label={label} name={name} + transform={(value) => + value + .toLowerCase() + .replace(/[\s_]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + } + // https://www.stefanjudis.com/snippets/turn-off-password-managers/ + data-1p-ignore + data-bwignore + data-lpignore="true" + data-form-type="other" {...textFieldProps} /> ) diff --git a/app/components/form/fields/TextField.tsx b/app/components/form/fields/TextField.tsx index 9163a02e48..46b2f41c22 100644 --- a/app/components/form/fields/TextField.tsx +++ b/app/components/form/fields/TextField.tsx @@ -47,7 +47,7 @@ export interface TextFieldProps< validate?: Validate, TFieldValues> control: Control /** Alters the value of the input during the field's onChange event. */ - transform?: (value: string) => FieldPathValue + transform?: (value: string) => string } export function TextField< diff --git a/app/components/form/fields/ip-pool-item.tsx b/app/components/form/fields/ip-pool-item.tsx new file mode 100644 index 0000000000..cc977e0639 --- /dev/null +++ b/app/components/form/fields/ip-pool-item.tsx @@ -0,0 +1,30 @@ +/* + * 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 type { SiloIpPool } from '~/api' +import { Badge } from '~/ui/lib/Badge' + +export function toIpPoolItem(p: SiloIpPool) { + const value = p.name + const selectedLabel = p.name + const label = ( +
+
+ {p.name} + {p.isDefault && ( + + default + + )} +
+ {!!p.description && ( +
{p.description}
+ )} +
+ ) + return { value, selectedLabel, label } +} diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index c3671a67f7..593056f4df 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -28,6 +28,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { RadioField } from '~/components/form/fields/RadioField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' @@ -76,7 +77,7 @@ export function CreateDiskSideModalForm({ const createDisk = useApiMutation('diskCreate', { onSuccess(data) { queryClient.invalidateQueries('diskList') - addToast({ content: 'Your disk has been created' }) + addToast(<>Disk {data.name} created) // prettier-ignore onSuccess?.(data) onDismiss(navigate) }, diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index 99bebe081f..35aee97230 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -18,6 +18,7 @@ import { } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' @@ -74,9 +75,10 @@ export function CreateFirewallRuleForm() { const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector)) const updateRules = useApiMutation('vpcFirewallRulesUpdate', { - onSuccess() { + onSuccess(updatedRules) { + const newRule = updatedRules.rules[updatedRules.rules.length - 1] queryClient.invalidateQueries('vpcFirewallRulesView') - addToast({ content: 'Your firewall rule has been created' }) + addToast(<>Firewall rule {newRule.name} created) // prettier-ignore navigate(pb.vpcFirewallRules(vpcSelector)) }, }) diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 50957bff10..bbea4f975e 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -18,11 +18,13 @@ import { import { trigger404 } from '~/components/ErrorBoundary' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getFirewallRuleSelector, useFirewallRuleSelector, useVpcSelector, } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' import { invariant } from '~/util/invariant' import { pb } from '~/util/path-builder' @@ -64,13 +66,15 @@ export function EditFirewallRuleForm() { const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector)) const updateRules = useApiMutation('vpcFirewallRulesUpdate', { - onSuccess() { + onSuccess(updatedRules, { body }) { // Nav before the invalidate because I once saw the above invariant fail // briefly after successful edit (error page flashed but then we land // on the rules list ok) and I think it was a race condition where the // invalidate managed to complete while the modal was still open. onDismiss() queryClient.invalidateQueries('vpcFirewallRulesView') + const updatedRule = body.rules[body.rules.length - 1] + addToast(<>Firewall rule {updatedRule.name} updated) // prettier-ignore }, }) diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 77424696c7..cab5b694e6 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -15,40 +15,21 @@ import { useApiQuery, useApiQueryClient, type FloatingIpCreate, - type SiloIpPool, } from '@oxide/api' import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Badge } from '~/ui/lib/Badge' import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -const toListboxItem = (p: SiloIpPool) => { - if (!p.isDefault) { - return { value: p.name, label: p.name } - } - // For the default pool, add a label to the dropdown - return { - value: p.name, - selectedLabel: p.name, - label: ( - <> - {p.name}{' '} - - default - - - ), - } -} - const defaultValues: Omit = { name: '', description: '', @@ -65,10 +46,10 @@ export function CreateFloatingIpSideModalForm() { const navigate = useNavigate() const createFloatingIp = useApiMutation('floatingIpCreate', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('ipPoolUtilizationView') - addToast({ content: 'Your Floating IP has been created' }) + addToast(<>Floating IP {floatingIp.name} created) // prettier-ignore navigate(pb.floatingIps(projectSelector)) }, }) @@ -108,7 +89,7 @@ export function CreateFloatingIpSideModalForm() { toListboxItem(p))} + items={(allPools?.items || []).map(toIpPoolItem)} label="IP pool" control={form.control} placeholder="Select a pool" diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index 44b19bd538..26fe356f92 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getFloatingIpSelector, useFloatingIpSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from 'app/util/path-builder' @@ -47,7 +48,7 @@ export function EditFloatingIpSideModalForm() { const editFloatingIp = useApiMutation('floatingIpUpdate', { onSuccess(_floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been updated' }) + addToast(<>Floating IP {_floatingIp.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 6026beb611..e2f5548b25 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -16,6 +16,7 @@ import { FileField } from '~/components/form/fields/FileField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useSiloSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' @@ -53,9 +54,9 @@ export function CreateIdpSideModalForm() { const onDismiss = () => navigate(pb.silo({ silo })) const createIdp = useApiMutation('samlIdentityProviderCreate', { - onSuccess() { + onSuccess(idp) { queryClient.invalidateQueries('siloIdentityProviderList') - addToast({ content: 'Your identity provider has been created' }) + addToast(<>IdP {idp.name} created) // prettier-ignore onDismiss() }, }) @@ -176,7 +177,7 @@ export function CreateIdpSideModalForm() { diff --git a/app/forms/idp/edit.tsx b/app/forms/idp/edit.tsx index a5cbc1e07a..b51d1d9043 100644 --- a/app/forms/idp/edit.tsx +++ b/app/forms/idp/edit.tsx @@ -104,14 +104,14 @@ export function EditIdpSideModalForm() { control={form.control} disabled /> - {/* TODO: add group attribute name when it is added to the API - */} + {/* TODO: Email field, probably */} navigate(pb.snapshots({ project })) const createImage = useApiMutation('imageCreate', { - onSuccess() { + onSuccess(image) { queryClient.invalidateQueries('imageList') - addToast({ content: 'Your image has been created' }) + addToast(<>Image {image.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 35f91958c5..8350ccf20a 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -25,6 +25,7 @@ import { type InstanceCreate, type InstanceDiskAttachment, type NameOrId, + type SiloIpPool, } from '@oxide/api' import { Images16Icon, @@ -46,6 +47,7 @@ import { } from '~/components/form/fields/DisksTableField' import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' +import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -57,7 +59,6 @@ import { FullPageForm } from '~/components/form/FullPageForm' import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Badge } from '~/ui/lib/Badge' import { Button } from '~/ui/lib/Button' import { Checkbox } from '~/ui/lib/Checkbox' import { toComboboxItems } from '~/ui/lib/Combobox' @@ -183,7 +184,7 @@ export function CreateInstanceForm() { { path: { instance: instance.name }, query: { project } }, instance ) - addToast({ content: 'Your instance has been created' }) + addToast(<>Instance {instance.name} created) // prettier-ignore navigate(pb.instance({ project, instance: instance.name })) }, }) @@ -609,7 +610,7 @@ const AdvancedAccordion = ({ }: { control: Control isSubmitting: boolean - siloPools: Array<{ name: string; isDefault: boolean }> + siloPools: Array }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -733,17 +734,7 @@ const AdvancedAccordion = ({ label="IP pool for ephemeral IP" placeholder={defaultPool ? `${defaultPool} (default)` : 'Select a pool'} selected={`${siloPools.find((pool) => pool.name === selectedPool)?.name}`} - items={ - siloPools.map((pool) => ({ - label: ( -
- {pool.name} - {pool.isDefault && default} -
- ), - value: pool.name, - })) || [] - } + items={siloPools.map(toIpPoolItem)} disabled={!assignEphemeralIp || isSubmitting} required onChange={(value) => { diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index c91e8d8d31..8afa803e9e 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -13,7 +13,9 @@ import { useApiMutation, useApiQueryClient, type IpPoolCreate } from '@oxide/api import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' +import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' const defaultValues: IpPoolCreate = { @@ -30,7 +32,7 @@ export function CreateIpPoolSideModalForm() { const createPool = useApiMutation('ipPoolCreate', { onSuccess(_pool) { queryClient.invalidateQueries('ipPoolList') - addToast({ content: 'Your IP pool has been created' }) + addToast(<>IP pool {_pool.name} created) // prettier-ignore navigate(pb.ipPools()) }, }) @@ -51,6 +53,14 @@ export function CreateIpPoolSideModalForm() { > + ) } + +export const IpPoolVisibilityMessage = () => ( + +) diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index 73e2c942c5..cbd0b7db7d 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -18,10 +18,13 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' +import { IpPoolVisibilityMessage } from './ip-pool-create' + EditIpPoolSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { const { pool } = getIpPoolSelector(params) await apiQueryClient.prefetchQuery('ipPoolView', { path: { pool } }) @@ -41,7 +44,7 @@ export function EditIpPoolSideModalForm() { onSuccess(updatedPool) { queryClient.invalidateQueries('ipPoolList') navigate(pb.ipPool({ pool: updatedPool.name })) - addToast({ content: 'Your IP pool has been updated' }) + addToast(<>IP pool {updatedPool.name} updated) // prettier-ignore // Only invalidate if we're staying on the same page. If the name // _has_ changed, invalidating ipPoolView causes an error page to flash @@ -68,6 +71,7 @@ export function EditIpPoolSideModalForm() { > + ) } diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index 43a93b9414..e224016a8b 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -7,6 +7,7 @@ */ import { useMemo } from 'react' import { useForm } from 'react-hook-form' +import type { SetRequired } from 'type-fest' import { useApiQuery, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxide/api' @@ -19,10 +20,10 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' import { FormDivider } from '~/ui/lib/Divider' -const defaultValues: InstanceNetworkInterfaceCreate = { +const defaultValues: SetRequired = { name: '', description: '', - ip: undefined, + ip: '', subnetName: '', vpcName: '', } @@ -58,7 +59,7 @@ export function CreateNetworkInterfaceForm({ resourceName="network interface" title="Add network interface" onDismiss={onDismiss} - onSubmit={onSubmit} + onSubmit={({ ip, ...rest }) => onSubmit({ ip: ip.trim() || undefined, ...rest })} loading={loading} submitError={submitError} > @@ -81,12 +82,7 @@ export function CreateNetworkInterfaceForm({ required control={form.control} /> - (ip.trim() === '' ? undefined : ip)} - /> + ) } diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index c57bde7899..401403f900 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -20,7 +20,9 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextFieldInner } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' import * as MiniTable from '~/ui/lib/MiniTable' @@ -42,8 +44,9 @@ export function EditNetworkInterfaceForm({ const instanceSelector = useInstanceSelector() const editNetworkInterface = useApiMutation('instanceNetworkInterfaceUpdate', { - onSuccess() { + onSuccess(nic) { queryClient.invalidateQueries('instanceNetworkInterfaceList') + addToast(<>Network interface {nic.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 826b587744..ae9551cd37 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -17,6 +17,7 @@ import { import { ListboxField } from '~/components/form/fields/ListboxField' import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { actorToItem, @@ -35,6 +36,8 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa const updatePolicy = useApiMutation('projectPolicyUpdate', { onSuccess: () => { queryClient.invalidateQueries('projectPolicyView') + // We don't have the name of the user or group, so we'll just have a generic message + addToast({ content: 'Role assigned' }) onDismiss() }, }) @@ -97,6 +100,7 @@ export function ProjectAccessEditUserSideModal({ const updatePolicy = useApiMutation('projectPolicyUpdate', { onSuccess: () => { queryClient.invalidateQueries('projectPolicyView') + addToast({ content: 'Role updated' }) onDismiss() }, }) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 020894826c..faaee13df7 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -13,6 +13,7 @@ import { useApiMutation, useApiQueryClient, type ProjectCreate } from '@oxide/ap import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -33,7 +34,7 @@ export function CreateProjectSideModalForm() { queryClient.invalidateQueries('projectList') // avoid the project fetch when the project page loads since we have the data queryClient.setQueryData('projectView', { path: { project: project.name } }, project) - addToast({ content: 'Your project has been created' }) + addToast(<>Project {project.name} created) // prettier-ignore navigate(pb.project({ project: project.name })) }, }) diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index 284c1de8de..7af23a1723 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -45,7 +46,7 @@ export function EditProjectSideModalForm() { queryClient.invalidateQueries('projectList') // avoid the project fetch when the project page loads since we have the data queryClient.setQueryData('projectView', { path: { project: project.name } }, project) - addToast({ content: 'Your project has been updated' }) + addToast(<>Project {project.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 9508386bae..ea6b82651e 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -19,6 +19,7 @@ import { RadioField } from '~/components/form/fields/RadioField' import { TextField } from '~/components/form/fields/TextField' import { TlsCertsField } from '~/components/form/fields/TlsCertsField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' @@ -57,7 +58,7 @@ export function CreateSiloSideModalForm() { onSuccess(silo) { queryClient.invalidateQueries('siloList') queryClient.setQueryData('siloView', { path: { silo: silo.name } }, silo) - addToast({ content: 'Your silo has been created' }) + addToast(<>Silo {silo.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx index 930cec2381..25c7f90db8 100644 --- a/app/forms/snapshot-create.tsx +++ b/app/forms/snapshot-create.tsx @@ -22,6 +22,7 @@ import { ComboboxField } from '~/components/form/fields/ComboboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { toComboboxItems } from '~/ui/lib/Combobox' @@ -52,9 +53,9 @@ export function CreateSnapshotSideModalForm() { const onDismiss = () => navigate(pb.snapshots(projectSelector)) const createSnapshot = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(snapshot) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Your snapshot has been created' }) + addToast(<>Snapshot {snapshot.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/ssh-key-create.tsx b/app/forms/ssh-key-create.tsx index 14e5b399a3..82ba183e23 100644 --- a/app/forms/ssh-key-create.tsx +++ b/app/forms/ssh-key-create.tsx @@ -14,6 +14,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -35,10 +36,10 @@ export function CreateSSHKeySideModalForm({ onDismiss, message }: Props) { const handleDismiss = onDismiss ? onDismiss : () => navigate(pb.sshKeys()) const createSshKey = useApiMutation('currentUserSshKeyCreate', { - onSuccess() { + onSuccess(sshKey) { queryClient.invalidateQueries('currentUserSshKeyList') handleDismiss() - addToast({ content: 'Your SSH key has been created' }) + addToast(<>SSH key {sshKey.name} created) // prettier-ignore }, }) const form = useForm({ defaultValues }) diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx index 5ed229999c..e2bbb2666a 100644 --- a/app/forms/subnet-create.tsx +++ b/app/forms/subnet-create.tsx @@ -20,7 +20,9 @@ import { useCustomRouterItems, } from '~/components/form/fields/useItemsList' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useVpcSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -42,9 +44,10 @@ export function CreateSubnetForm() { const onDismiss = () => navigate(pb.vpcSubnets(vpcSelector)) const createSubnet = useApiMutation('vpcSubnetCreate', { - onSuccess() { + onSuccess(subnet) { queryClient.invalidateQueries('vpcSubnetList') onDismiss() + addToast(<>Subnet {subnet.name} created) // prettier-ignore }, }) diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 6bfd7e18c1..49ab973fbc 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -25,7 +25,9 @@ import { useCustomRouterItems, } from '~/components/form/fields/useItemsList' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSubnetSelector, useVpcSubnetSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -51,8 +53,9 @@ export function EditSubnetForm() { }) const updateSubnet = useApiMutation('vpcSubnetUpdate', { - onSuccess() { + onSuccess(subnet) { queryClient.invalidateQueries('vpcSubnetList') + addToast(<>Subnet {subnet.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index f93d040b8c..43f8fa15a1 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -14,6 +14,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -38,7 +39,7 @@ export function CreateVpcSideModalForm() { { path: { vpc: vpc.name }, query: projectSelector }, vpc ) - addToast({ content: 'Your VPC has been created' }) + addToast(<>VPC {vpc.name} created) // prettier-ignore navigate(pb.vpc({ vpc: vpc.name, ...projectSelector })) }, }) diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index 9a6380f5fa..0982d17f10 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -42,7 +43,7 @@ export function EditVpcSideModalForm() { onSuccess(updatedVpc) { queryClient.invalidateQueries('vpcList') navigate(pb.vpc({ project, vpc: updatedVpc.name })) - addToast({ content: 'Your VPC has been updated' }) + addToast(<>VPC {updatedVpc.name} updated) // prettier-ignore // Only invalidate if we're staying on the same page. If the name // _has_ changed, invalidating vpcView causes an error page to flash diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx index c808d3a099..3d08d456cc 100644 --- a/app/forms/vpc-router-create.tsx +++ b/app/forms/vpc-router-create.tsx @@ -13,6 +13,7 @@ import { useApiMutation, useApiQueryClient, type VpcRouterCreate } from '@oxide/ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -30,9 +31,9 @@ export function CreateRouterSideModalForm() { const onDismiss = () => navigate(pb.vpcRouters(vpcSelector)) const createRouter = useApiMutation('vpcRouterCreate', { - onSuccess() { + onSuccess(router) { queryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been created' }) + addToast(<>Router {router.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 3d8067022d..134aadcf26 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -23,6 +23,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -51,9 +52,9 @@ export function EditRouterSideModalForm() { } const editRouter = useApiMutation('vpcRouterUpdate', { - onSuccess() { + onSuccess(updatedRouter) { queryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been updated' }) + addToast(<>Router {updatedRouter.name} updated) // prettier-ignore navigate(pb.vpcRouters({ project, vpc })) }, }) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 4ed2afe6c0..8030b55dcd 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -11,6 +11,7 @@ import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, useApiQueryClient } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { RouteFormFields, type RouteFormValues } from '~/forms/vpc-router-route-common' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' @@ -44,9 +45,9 @@ export function CreateRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { - onSuccess() { + onSuccess(route) { queryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been created' }) + addToast(<>Route {route.name} created) // prettier-ignore navigate(pb.vpcRouter(routerSelector)) }, }) diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index 19ac9934e2..da1c06338e 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -17,6 +17,7 @@ import { } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { RouteFormFields, routeFormMessage, @@ -62,9 +63,9 @@ export function EditRouterRouteSideModalForm() { const disabled = route?.kind === 'vpc_subnet' const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', { - onSuccess() { + onSuccess(updatedRoute) { queryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been updated' }) + addToast(<>Route {updatedRoute.name} updated) // prettier-ignore navigate(pb.vpcRouter(routerSelector)) }, }) diff --git a/app/pages/LoginPage.tsx b/app/pages/LoginPage.tsx index 92b14cc7f2..4ff7a48d2d 100644 --- a/app/pages/LoginPage.tsx +++ b/app/pages/LoginPage.tsx @@ -35,7 +35,7 @@ export function LoginPage() { useEffect(() => { if (loginPost.isSuccess) { - addToast({ title: 'Logged in' }) + addToast('Logged in') navigate(searchParams.get('redirect_uri') || pb.projects()) } }, [loginPost.isSuccess, navigate, searchParams]) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 832749910e..05173294af 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -34,6 +34,7 @@ import { } from '~/forms/project-access' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' @@ -119,7 +120,10 @@ export function ProjectAccessPage() { const queryClient = useApiQueryClient() const { mutateAsync: updatePolicy } = useApiMutation('projectPolicyUpdate', { - onSuccess: () => queryClient.invalidateQueries('projectPolicyView'), + onSuccess: () => { + queryClient.invalidateQueries('projectPolicyView') + addToast({ content: 'Access removed' }) + }, // TODO: handle 403 }) diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 11e0c215d6..298e4af5f9 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -20,6 +20,7 @@ import { import { Storage16Icon, Storage24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { DiskStateBadge } from '~/components/StateBadge' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -99,15 +100,16 @@ export function DisksPage() { const { Table } = useQueryTable('diskList', { query: { project } }) const { mutateAsync: deleteDisk } = useApiMutation('diskDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('diskList') + addToast(<>Disk {variables.path.disk} deleted) // prettier-ignore }, }) const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Snapshot successfully created' }) + addToast(<>Snapshot {variables.body.name} created) // prettier-ignore }, onError(err) { addToast({ @@ -123,7 +125,7 @@ export function DisksPage() { { label: 'Snapshot', onActivate() { - addToast({ title: `Creating snapshot of disk '${disk.name}'` }) + addToast(<>Creating snapshot of disk {disk.name}) // prettier-ignore createSnapshot({ query: { project }, body: { diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index 08d035c033..ae5b95b57c 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -108,19 +108,19 @@ export function FloatingIpsPage() { const navigate = useNavigate() const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been detached' }) + addToast(<>Floating IP {floatingIp.name} detached) // prettier-ignore }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) const { mutateAsync: deleteFloatingIp } = useApiMutation('floatingIpDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('ipPoolUtilizationView') - addToast({ content: 'Your floating IP has been deleted' }) + addToast(<>Floating IP {variables.path.floatingIp} deleted) // prettier-ignore }, }) @@ -250,9 +250,9 @@ const AttachFloatingIpModal = ({ }) => { const queryClient = useApiQueryClient() const floatingIpAttach = useApiMutation('floatingIpAttach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been attached' }) + addToast(<>Floating IP {floatingIp.name} attached) // prettier-ignore onDismiss() }, onError: (err) => { diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index 726357a6f8..cf5c43dee2 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -13,6 +13,7 @@ import { apiQueryClient, useApiMutation, useApiQueryClient, type Image } from '@ import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -58,7 +59,7 @@ export function ImagesPage() { const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { onSuccess(_data, variables) { - addToast({ content: `${variables.path.image} has been deleted` }) + addToast(<>Image {variables.path.image} deleted) // prettier-ignore queryClient.invalidateQueries('imageList') }, }) @@ -131,7 +132,11 @@ const PromoteImageModal = ({ onDismiss, imageName }: PromoteModalProps) => { const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { addToast({ - content: `${data.name} has been promoted`, + content: ( + <> + Image {data.name} promoted + + ), cta: { text: 'View silo images', link: '/images', diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 343f4c1871..97de119b4f 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -64,8 +64,7 @@ const POLL_INTERVAL_SLOW = 60 * sec export function InstancesPage() { const { project } = useProjectSelector() - - const makeActions = useMakeInstanceActions( + const { makeButtonActions, makeMenuActions } = useMakeInstanceActions( { project }, { onSuccess: refetchInstances, onDelete: refetchInstances } ) @@ -182,9 +181,12 @@ export function InstancesPage() { } ), colHelper.accessor('timeCreated', Columns.timeCreated), - getActionsCol(makeActions), + getActionsCol((instance: Instance) => [ + ...makeButtonActions(instance), + ...makeMenuActions(instance), + ]), ], - [project, makeActions] + [project, makeButtonActions, makeMenuActions] ) if (!instances) return null diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index df251f3ab5..6b50afd038 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -14,7 +14,6 @@ import { HL } from '~/components/HL' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import type { MakeActions } from '~/table/columns/action-col' import { pb } from '~/util/path-builder' import { fancifyStates } from './instance/tabs/common' @@ -31,9 +30,8 @@ type Options = { export const useMakeInstanceActions = ( { project }: { project: string }, options: Options = {} -): MakeActions => { +) => { const navigate = useNavigate() - // if you also pass onSuccess to mutate(), this one is not overridden — this // one runs first, then the one passed to mutate(). // @@ -41,7 +39,7 @@ export const useMakeInstanceActions = ( // while the whole useMutation result object is not. The async ones are used // when we need to confirm because the confirm modals want that. const opts = { onSuccess: options.onSuccess } - const { mutate: startInstance } = useApiMutation('instanceStart', opts) + const { mutateAsync: startInstanceAsync } = useApiMutation('instanceStart', opts) const { mutateAsync: stopInstanceAsync } = useApiMutation('instanceStop', opts) const { mutate: rebootInstance } = useApiMutation('instanceReboot', opts) // delete has its own @@ -49,22 +47,32 @@ export const useMakeInstanceActions = ( onSuccess: options.onDelete, }) - return useCallback( - (instance) => { - const instanceSelector = { project, instance: instance.name } + const makeButtonActions = useCallback( + (instance: Instance) => { const instanceParams = { path: { instance: instance.name }, query: { project } } return [ { label: 'Start', onActivate() { - startInstance(instanceParams, { - onSuccess: () => addToast({ title: `Starting instance '${instance.name}'` }), - onError: (error) => - addToast({ - variant: 'error', - title: `Error starting instance '${instance.name}'`, - content: error.message, + confirmAction({ + actionType: 'primary', + doAction: () => + startInstanceAsync(instanceParams, { + onSuccess: () => addToast(<>Starting instance {instance.name}), // prettier-ignore + onError: (error) => + addToast({ + variant: 'error', + title: `Error starting instance '${instance.name}'`, + content: error.message, + }), }), + modalTitle: 'Confirm start instance', + modalContent: ( +

+ Are you sure you want to start {instance.name}? +

+ ), + errorTitle: `Error starting ${instance.name}`, }) }, disabled: !instanceCan.start(instance) && ( @@ -79,7 +87,7 @@ export const useMakeInstanceActions = ( doAction: () => stopInstanceAsync(instanceParams, { onSuccess: () => - addToast({ title: `Stopping instance '${instance.name}'` }), + addToast(<>Stopping instance {instance.name}), // prettier-ignore }), modalTitle: 'Confirm stop instance', modalContent: ( @@ -97,14 +105,26 @@ export const useMakeInstanceActions = ( }) }, disabled: !instanceCan.stop(instance) && ( - <>Only {fancifyStates(instanceCan.stop.states)} instances can be stopped + // don't list all the states, it's overwhelming + <>Only {fancifyStates(['running'])} instances can be stopped ), }, + ] + }, + [project, startInstanceAsync, stopInstanceAsync] + ) + + const makeMenuActions = useCallback( + (instance: Instance) => { + const instanceSelector = { project, instance: instance.name } + const instanceParams = { path: { instance: instance.name }, query: { project } } + return [ { label: 'Reboot', onActivate() { rebootInstance(instanceParams, { - onSuccess: () => addToast({ title: `Rebooting instance '${instance.name}'` }), + onSuccess: () => + addToast(<>Rebooting instance {instance.name}), // prettier-ignore onError: (error) => addToast({ variant: 'error', @@ -129,7 +149,7 @@ export const useMakeInstanceActions = ( doDelete: () => deleteInstanceAsync(instanceParams, { onSuccess: () => - addToast({ title: `Deleting instance '${instance.name}'` }), + addToast(<>Deleting instance {instance.name}), // prettier-ignore }), label: instance.name, resourceKind: 'instance', @@ -142,13 +162,8 @@ export const useMakeInstanceActions = ( }, ] }, - [ - project, - navigate, - deleteInstanceAsync, - rebootInstance, - startInstance, - stopInstanceAsync, - ] + [project, deleteInstanceAsync, navigate, rebootInstance] ) + + return { makeButtonActions, makeMenuActions } } diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 9736ad1bef..f9c7fc30cc 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -26,6 +26,7 @@ import { RouteTabs, Tab } from '~/components/RouteTabs' import { InstanceStateBadge } from '~/components/StateBadge' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { EmptyCell } from '~/table/cells/EmptyCell' +import { Button } from '~/ui/lib/Button' import { DateTime } from '~/ui/lib/DateTime' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' @@ -92,7 +93,8 @@ export function InstancePage() { const instanceSelector = useInstanceSelector() const navigate = useNavigate() - const makeActions = useMakeInstanceActions(instanceSelector, { + + const { makeButtonActions, makeMenuActions } = useMakeInstanceActions(instanceSelector, { onSuccess: refreshData, // go to project instances list since there's no more instance onDelete: () => { @@ -132,7 +134,7 @@ export function InstancePage() { { enabled: !!primaryVpcId } ) - const actions = useMemo( + const allMenuActions = useMemo( () => [ { label: 'Copy ID', @@ -140,9 +142,9 @@ export function InstancePage() { window.navigator.clipboard.writeText(instance.id || '') }, }, - ...makeActions(instance), + ...makeMenuActions(instance), ], - [instance, makeActions] + [instance, makeMenuActions] ) const memory = filesize(instance.memory, { output: 'object', base: 2 }) @@ -152,9 +154,23 @@ export function InstancePage() { }>{instance.name}
- - + +
+ {makeButtonActions(instance).map((action) => ( + + ))} +
+
diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 0082f7cfc4..d1d8c11431 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -202,9 +202,9 @@ export function NetworkingTab() { }, }) const { mutateAsync: deleteNic } = useApiMutation('instanceNetworkInterfaceDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('instanceNetworkInterfaceList') - addToast({ content: 'Network interface deleted' }) + addToast(<>Network interface {variables.path.interface} deleted) // prettier-ignore }, }) const { mutate: editNic } = useApiMutation('instanceNetworkInterfaceUpdate', { @@ -297,7 +297,7 @@ export function NetworkingTab() { const { mutateAsync: ephemeralIpDetach } = useApiMutation('instanceEphemeralIpDetach', { onSuccess() { queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your ephemeral IP has been detached' }) + addToast({ content: 'Ephemeral IP detached' }) }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) @@ -305,10 +305,10 @@ export function NetworkingTab() { }) const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your floating IP has been detached' }) + addToast(<>Floating IP {variables.path.floatingIp} detached) // prettier-ignore }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx index 1083b509e5..dbac4b0f67 100644 --- a/app/pages/project/instances/instance/tabs/StorageTab.tsx +++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx @@ -87,9 +87,9 @@ export function StorageTab() { ) const { mutate: detachDisk } = useApiMutation('instanceDiskDetach', { - onSuccess() { + onSuccess(disk) { queryClient.invalidateQueries('instanceDiskList') - addToast({ content: 'Disk detached' }) + addToast(<>Disk {disk.name} detached) // prettier-ignore }, onError(err) { addToast({ @@ -100,9 +100,9 @@ export function StorageTab() { }, }) const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(snapshot) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Snapshot created' }) + addToast(<>Snapshot {snapshot.name} created) // prettier-ignore }, onError(err) { addToast({ @@ -161,7 +161,11 @@ export function StorageTab() { doAction: () => instanceUpdate({ path: { instance: instance.id }, - body: { bootDisk: undefined }, + body: { + bootDisk: undefined, + // this would get unset if we left it out + autoRestartPolicy: instance.autoRestartPolicy, + }, }), errorTitle: 'Could not unset boot disk', modalTitle: 'Confirm unset boot disk', @@ -189,7 +193,7 @@ export function StorageTab() { onActivate() {}, // it's always disabled, so noop is ok }, ], - [instanceUpdate, instance.id, getSnapshotAction] + [instanceUpdate, instance, getSnapshotAction] ) const makeOtherDiskActions = useCallback( @@ -210,7 +214,11 @@ export function StorageTab() { doAction: () => instanceUpdate({ path: { instance: instance.id }, - body: { bootDisk: disk.id }, + body: { + bootDisk: disk.id, + // this would get unset if we left it out + autoRestartPolicy: instance.autoRestartPolicy, + }, }), errorTitle: `Could not ${verb} boot disk`, modalTitle: `Confirm ${verb} boot disk`, @@ -245,7 +253,7 @@ export function StorageTab() { }, }, ], - [detachDisk, instanceUpdate, instance.id, getSnapshotAction, bootDisks] + [detachDisk, instanceUpdate, instance, getSnapshotAction, bootDisks] ) const attachDisk = useApiMutation('instanceDiskAttach', { diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index b91ae862a5..1374602b31 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -90,7 +90,8 @@ export function RouterPage() { const { mutateAsync: deleteRouterRoute } = useApiMutation('vpcRouterRouteDelete', { onSuccess() { apiQueryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been deleted' }) + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'Route deleted' }) }, }) diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index 5c5c5d912a..97adda95eb 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -16,6 +16,7 @@ import { } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' +import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { RouteTabs, Tab } from '~/components/RouteTabs' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' @@ -46,10 +47,10 @@ export function VpcPage() { }) const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('vpcList') navigate(pb.vpcs({ project })) - addToast({ content: 'Your VPC has been deleted' }) + addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore }, }) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 361d7a4921..cd411c4c90 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -11,6 +11,7 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, type VpcRouter } from '@oxide/api' +import { HL } from '~/components/HL' import { routeFormMessage } from '~/forms/vpc-router-route-common' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -62,9 +63,9 @@ export function VpcRoutersTab() { ) const { mutateAsync: deleteRouter } = useApiMutation('vpcRouterDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been deleted' }) + addToast(<>Router {variables.path.router} deleted) // prettier-ignore }, }) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx index 285bb2b82c..0dcb974a19 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx @@ -18,6 +18,7 @@ import { import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' import { RouterLinkCell } from '~/table/cells/RouterLinkCell' import { TwoLineCell } from '~/table/cells/TwoLineCell' @@ -47,6 +48,8 @@ export function VpcSubnetsTab() { const { mutateAsync: deleteSubnet } = useApiMutation('vpcSubnetDelete', { onSuccess() { queryClient.invalidateQueries('vpcSubnetList') + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'Subnet deleted' }) }, }) diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index e5ce773f28..69df4371e3 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -20,6 +20,7 @@ import { import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' @@ -83,9 +84,9 @@ export function VpcsPage() { const navigate = useNavigate() const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('vpcList') - addToast({ content: 'Your VPC has been deleted' }) + addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore }, }) diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 97ad48f883..3b2fd881c8 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -13,6 +13,7 @@ import { apiQueryClient, useApiMutation, useApiQueryClient, type SshKey } from ' import { Key16Icon, Key24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -46,9 +47,9 @@ export function SSHKeysPage() { const queryClient = useApiQueryClient() const { mutateAsync: deleteSshKey } = useApiMutation('currentUserSshKeyDelete', { - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries('currentUserSshKeyList') - addToast({ content: 'Your SSH key has been deleted' }) + addToast(<>SSH key {variables.path.sshKey} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/SiloImagesPage.tsx b/app/pages/system/SiloImagesPage.tsx index 6c27a1cf53..2346153936 100644 --- a/app/pages/system/SiloImagesPage.tsx +++ b/app/pages/system/SiloImagesPage.tsx @@ -23,6 +23,7 @@ import { DocsPopover } from '~/components/DocsPopover' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { toImageComboboxItem } from '~/components/form/fields/ImageSelectField' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -72,7 +73,7 @@ export function SiloImagesPage() { const queryClient = useApiQueryClient() const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { onSuccess(_data, variables) { - addToast({ content: `${variables.path.image} has been deleted` }) + addToast(<>Image {variables.path.image} deleted) // prettier-ignore queryClient.invalidateQueries('imageList') }, }) @@ -131,7 +132,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { - addToast({ content: `${data.name} has been promoted` }) + addToast(<>Image {data.name} promoted) // prettier-ignore queryClient.invalidateQueries('imageList') }, onError: (err) => { @@ -218,7 +219,11 @@ const DemoteImageModal = ({ const demoteImage = useApiMutation('imageDemote', { onSuccess(data) { addToast({ - content: `${data.name} has been demoted`, + content: ( + <> + Image {data.name} demoted + + ), cta: selectedProject ? { text: `View images in ${selectedProject}`, diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 7461dc41c1..a5c44487e7 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -82,10 +82,10 @@ export function IpPoolPage() { }) const navigate = useNavigate() const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('ipPoolList') navigate(pb.ipPools()) - addToast({ content: 'IP pool deleted' }) + addToast(<>Pool {variables.path.pool} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index 8084dc77f7..eaa8fcf386 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -20,6 +20,7 @@ import { import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { IpUtilCell } from '~/components/IpPoolUtilization' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' @@ -78,9 +79,9 @@ export function IpPoolsPage() { }) const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('ipPoolList') - addToast({ content: 'IP pool deleted' }) + addToast(<>Pool {variables.path.pool} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 81aef8d4c9..b19a51ba88 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -81,6 +81,8 @@ export function SiloIpPoolsTab() { const { mutateAsync: unlinkPool } = useApiMutation('ipPoolSiloUnlink', { onSuccess() { queryClient.invalidateQueries('siloIpPoolList') + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'IP pool unlinked' }) }, }) diff --git a/app/pages/system/silos/SiloQuotasTab.tsx b/app/pages/system/silos/SiloQuotasTab.tsx index 14df8fbb74..8037974cd1 100644 --- a/app/pages/system/silos/SiloQuotasTab.tsx +++ b/app/pages/system/silos/SiloQuotasTab.tsx @@ -18,6 +18,7 @@ import { import { NumberField } from '~/components/form/fields/NumberField' import { SideModalForm } from '~/components/form/SideModalForm' import { useSiloSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' import { Message } from '~/ui/lib/Message' import { Table } from '~/ui/lib/Table' @@ -106,6 +107,7 @@ function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) { const updateQuotas = useApiMutation('siloQuotasUpdate', { onSuccess() { apiQueryClient.invalidateQueries('siloUtilizationView') + addToast({ content: 'Quotas updated' }) onDismiss() }, }) diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index 6fbec47227..7f10b98449 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -19,8 +19,10 @@ import { import { Cloud16Icon, Cloud24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { BooleanCell } from '~/table/cells/BooleanCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -76,8 +78,9 @@ export function SilosPage() { }) const { mutateAsync: deleteSilo } = useApiMutation('siloDelete', { - onSuccess() { + onSuccess(silo, { path }) { queryClient.invalidateQueries('siloList') + addToast(<>Silo {path.silo} deleted) // prettier-ignore }, }) diff --git a/app/stores/toast.ts b/app/stores/toast.ts index ea06db7213..6bf3c4f5e8 100644 --- a/app/stores/toast.ts +++ b/app/stores/toast.ts @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { type ReactElement } from 'react' import { v4 as uuid } from 'uuid' import { create } from 'zustand' @@ -17,9 +18,18 @@ type Toast = { export const useToastStore = create<{ toasts: Toast[] }>(() => ({ toasts: [] })) -export function addToast(options: Toast['options']) { +/** + * If argument is `ReactElement | string`, use it directly as `{ content }`. + * Otherwise it's a config object. + */ +export function addToast(optionsOrContent: Toast['options'] | ReactElement | string) { + const options = + typeof optionsOrContent === 'object' && 'content' in optionsOrContent + ? optionsOrContent + : { content: optionsOrContent } useToastStore.setState(({ toasts }) => ({ toasts: [...toasts, { id: uuid(), options }] })) } + export function removeToast(id: Toast['id']) { useToastStore.setState(({ toasts }) => ({ toasts: toasts.filter((t) => t.id !== id) })) } diff --git a/app/table/columns/action-col.tsx b/app/table/columns/action-col.tsx index f18d16a04a..a880245b45 100644 --- a/app/table/columns/action-col.tsx +++ b/app/table/columns/action-col.tsx @@ -16,7 +16,7 @@ import { Tooltip } from '~/ui/lib/Tooltip' import { Wrap } from '~/ui/util/wrap' import { kebabCase } from '~/util/str' -export type MakeActions = (item: Item) => Array +type MakeActions = (item: Item) => Array export type MenuAction = { label: string diff --git a/app/ui/lib/Button.tsx b/app/ui/lib/Button.tsx index 1ced212a58..d77c893b6d 100644 --- a/app/ui/lib/Button.tsx +++ b/app/ui/lib/Button.tsx @@ -35,7 +35,7 @@ export const buttonStyle = ({ variant = 'primary', }: ButtonStyleProps = {}) => { return cn( - 'ox-button elevation-1 rounded inline-flex items-center justify-center align-top disabled:cursor-not-allowed shrink-0', + 'ox-button elevation-1 rounded inline-flex items-center justify-center align-top disabled:cursor-default shrink-0', `btn-${variant}`, sizeStyle[size], variant === 'danger' @@ -87,7 +87,7 @@ export const Button = forwardRef( return ( } + with={} >