diff --git a/app/api/__tests__/hooks.spec.tsx b/app/api/__tests__/client.spec.tsx similarity index 89% rename from app/api/__tests__/hooks.spec.tsx rename to app/api/__tests__/client.spec.tsx index 5bde7b262..8f54f7ae5 100644 --- a/app/api/__tests__/hooks.spec.tsx +++ b/app/api/__tests__/client.spec.tsx @@ -11,7 +11,7 @@ import { describe, expect, it, vi } from 'vitest' import { project } from '@oxide/api-mocks' -import { apiq, useApiMutation } from '..' +import { api, q, useApiMutation } from '..' import type { DiskCreate } from '../__generated__/Api' import { overrideOnce } from '../../../test/unit/server' @@ -35,16 +35,17 @@ export function Wrapper({ children }: { children: React.ReactNode }) { const config = { wrapper: Wrapper } -const renderProjectList = () => renderHook(() => useQuery(apiq('projectList', {})), config) +const renderProjectList = () => renderHook(() => useQuery(q(api.projectList, {})), config) // 503 is a special key in the MSW server that returns a 503 const renderGetProject503 = () => renderHook( - () => useQuery(apiq('projectView', { path: { project: 'project-error-503' } })), + () => useQuery(q(api.projectView, { path: { project: 'project-error-503' } })), config ) -const renderCreateProject = () => renderHook(() => useApiMutation('projectCreate'), config) +const renderCreateProject = () => + renderHook(() => useApiMutation(api.projectCreate), config) const createParams = { body: { name: 'abc', description: '', hello: 'a' }, @@ -120,7 +121,7 @@ describe('useApiQuery', () => { function BadApiCall() { try { // oxlint-disable-next-line react-hooks/rules-of-hooks - useQuery(apiq('projectView', { path: { project: 'nonexistent' } })) + useQuery(q(api.projectView, { path: { project: 'nonexistent' } })) } catch (e) { onError(e) } @@ -143,8 +144,8 @@ describe('useApiQuery', () => { const { result } = renderHook( () => useQuery( - apiq( - 'projectView', + q( + api.projectView, { path: { project: 'nonexistent' } }, { throwOnError: false } ) @@ -205,7 +206,7 @@ describe('useApiMutation', () => { } it('passes through raw response', async () => { - const { result } = renderHook(() => useApiMutation('diskCreate'), config) + const { result } = renderHook(() => useApiMutation(api.diskCreate), config) act(() => result.current.mutate(diskCreate404Params)) @@ -219,7 +220,7 @@ describe('useApiMutation', () => { }) it('parses error json if possible', async () => { - const { result } = renderHook(() => useApiMutation('diskCreate'), config) + const { result } = renderHook(() => useApiMutation(api.diskCreate), config) act(() => result.current.mutate(diskCreate404Params)) @@ -286,3 +287,12 @@ describe('useApiMutation', () => { }) }) }) + +// we're relying on the name property of the API method for the queryKey, so we +// need to make sure nothing changes in the generated client to cause the API +// methods to not have a name +it('apiq queryKey', () => { + const params = { path: { silo: 'abc' } } + const queryOptions = q(api.siloView, { path: { silo: 'abc' } }) + expect(queryOptions.queryKey).toEqual(['siloView', params]) +}) diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index e7169cf88..163efa3f1 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -35,7 +35,7 @@ const grepFiles = (s: string) => it('mock-api is only referenced in test files', () => { expect(grepFiles('api-mocks')).toMatchInlineSnapshot(` [ - "app/api/__tests__/hooks.spec.tsx", + "app/api/__tests__/client.spec.tsx", "mock-api/msw/db.ts", "test/e2e/instance-create.e2e.ts", "test/e2e/inventory.e2e.ts", diff --git a/app/api/client.ts b/app/api/client.ts index 4fd383bb8..d797d8fcd 100644 --- a/app/api/client.ts +++ b/app/api/client.ts @@ -6,54 +6,38 @@ * Copyright Oxide Computer Company */ import { + hashKey, QueryClient as QueryClientOrig, + queryOptions, + useMutation, useQuery, + type QueryKey, + type UseMutationOptions, type UseQueryOptions, + type UseQueryResult, } from '@tanstack/react-query' +import * as R from 'remeda' +import { type SetNonNullable } from 'type-fest' -import { Api } from './__generated__/Api' -import { type ApiError } from './errors' -import { - ensurePrefetched, - getApiQueryOptions, - getApiQueryOptionsErrorsAllowed, - getListQueryOptionsFn, - getUseApiMutation, -} from './hooks' - -export const api = new Api({ +import { invariant } from '~/util/invariant' + +import { Api, type ApiResult } from './__generated__/Api' +import type { FetchParams } from './__generated__/http-client' +import { processServerError, type ApiError } from './errors' +import { navToLogin } from './nav-to-login' + +const _api = new Api({ // unit tests run in Node, whose fetch implementation requires a full URL host: process.env.NODE_ENV === 'test' ? 'http://testhost' : '', }) -export type ApiMethods = typeof api.methods - -/** API-specific query options helper. */ -export const apiq = getApiQueryOptions(api.methods) -/** - * Variant of `apiq` that allows error responses as a valid result, - * which importantly means they can be cached by RQ. This means we can prefetch - * an endpoint that might error (see `prefetchQueryErrorsAllowed`) and use this - * hook to retrieve the error result. - * - * Concretely, the difference from the usual query function is that we turn all - * errors into successes. Instead of throwing the error, we return it as a valid - * result. This means `data` has a type that includes the possibility of error, - * plus a discriminant to let us handle both sides properly in the calling code. - * - * We also use a special query key to distinguish these from normal API queries. - * If we hit a given endpoint twice on the same page, once the normal way and - * once with errors allowed, the responses have different shapes, so we do not - * want to share the cache and mix them up. - */ -export const apiqErrorsAllowed = getApiQueryOptionsErrorsAllowed(api.methods) -/** - * Query options helper that only supports list endpoints. Returns - * a function `(limit, pageToken) => QueryOptions` for use with - * `useQueryTable`. - */ -export const getListQFn = getListQueryOptionsFn(api.methods) -export const useApiMutation = getUseApiMutation(api.methods) +// Pull the methods off to make the call sites shorter: `api.siloView` +// instead of `api.methods.siloView`. We only put this method field there in +// the generator to make the old way of typing `q` work, so we don't need it +// anymore. I plan to change the client to put the methods at top level, and +// then we can just export the Api() object directly. +export const api = _api.methods +export const instanceSerialConsoleStream = _api.ws.instanceSerialConsoleStream /** * Same as `useQuery`, except we use `invariant(data)` to ensure the data is @@ -77,7 +61,7 @@ class QueryClient extends QueryClientOrig { * accidentally overspecifying and therefore failing to match the desired query. * The params argument can be added in if we ever have a use case for it. */ - invalidateEndpoint(method: keyof typeof api.methods) { + invalidateEndpoint(method: keyof typeof api) { return this.invalidateQueries({ queryKey: [method] }) } } @@ -93,3 +77,261 @@ export const queryClient = new QueryClient({ }, }, }) + +export type ResultsPage = { items: TItem[]; nextPage?: string | null } + +type HandledResult = { type: 'success'; data: T } | { type: 'error'; data: ApiError } + +// method: keyof Api would be strictly more correct, but making it a string +// means we can call this directly in all the spots below instead of having to +// make it generic over Api, which requires passing it as an argument to +// getUseApiQuery, etc. This is fine because it is only being called inside +// functions where `method` is already required to be an API method. +const handleResult = + (method: string) => + (result: ApiResult): HandledResult => { + if (result.type === 'success') return { type: 'success', data: result.data } + + // if logged out, hit /login to trigger login redirect + // Exception: 401 on password login POST needs to be handled in-page + if (result.response.status === 401 && method !== 'loginLocal') { + // TODO-usability: for background requests, a redirect to login without + // warning could come as a surprise to the user, especially because + // sometimes background requests are not directly triggered by a user + // action, e.g., polling or refetching when window regains focus + navToLogin({ includeCurrent: true }) + } + + const error = processServerError(method, result) + + // log to the console so it's there in case they open the dev tools, unlike + // network tab, which only records if dev tools are already open. but don't + // clutter test output + if (process.env.NODE_ENV !== 'test') { + const consolePage = window.location.pathname + window.location.search + // TODO: need to change oxide.ts to put the HTTP method on the result in + // order to log it here + console.error( + `More info about API ${error.statusCode || 'error'} on ${consolePage} + +API URL: ${result.response.url} +Request ID: ${error.requestId} +Error code: ${error.errorCode} +Error message: ${error.message.replace(/\n/g, '\n' + ' '.repeat('Error message: '.length))} +` + ) + } + + return { type: 'error', data: error } + } + +/** + * `queryKey` and `queryFn` are always constructed by our helper hooks, so we + * only allow the rest of the options. + */ +type UseQueryOtherOptions = Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' +> + +// Managed here instead of at the display layer so it can be built into the +// query options and shared between loader prefetch and QueryTable +export const PAGE_SIZE = 50 + +/** + * This primarily exists so we can have an object that encapsulates everything + * useQueryTable needs to know about a query. In particular, it needs the page + * size, and you can't pull that out of the query options object unless you + * stick it in `meta`, and then we don't have type safety. + */ +export type PaginatedQuery = { + optionsFn: ( + pageToken?: string + ) => UseQueryOptions & { queryKey: QueryKey } + pageSize: number +} + +/** + * Query options helper that only supports list endpoints. Returns + * a function `(limit, pageToken) => QueryOptions` for use with + * `useQueryTable`. + * + * Instead of returning the options directly, it returns a paginated + * query config object containing the page size and a function that + * takes `limit` and `pageToken` and merges them into the query params + * so that these can be passed in by `QueryTable`. + */ +export const getListQFn = < + Q, + Params extends { query?: Q }, + Data extends ResultsPage, +>( + f: (p: Params) => Promise>, + params: Params, + options: UseQueryOtherOptions = {} +): PaginatedQuery => { + // We pull limit out of the query params rather than passing it in some + // other way so that there is exactly one way of specifying it. If we had + // some other way of doing it, and then you also passed it in as a query + // param, it would be hard to guess which takes precedence. (pathOr plays + // nice when the properties don't exist.) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const limit = R.pathOr(params as any, ['query', 'limit'], PAGE_SIZE) + return { + optionsFn: (pageToken?: string) => { + const newParams = { ...params, query: { ...params.query, limit, pageToken } } + return q(f, newParams, { + ...options, + // identity function so current page sticks around while next loads + placeholderData: (x) => x, + }) + }, + pageSize: limit, + } +} + +const prefetchError = (key?: QueryKey) => + `Expected query to be prefetched. +Key: ${key ? hashKey(key) : ''} +Ensure the following: +• loader is called in routes.tsx and is running +• query matches in both the loader and the component +• request isn't erroring-out server-side (check the Networking tab) +• mock API endpoint is implemented in handlers.ts` + +/** + * Ensure a query result came from the cache by blowing up if `data` comes + * back undefined. + */ +export function ensurePrefetched( + result: UseQueryResult, + /** + * Optional because if we call this manually from a component like + * `ensure(useQuery(...))`, * we don't necessarily have access to the key. + */ + key?: QueryKey +) { + invariant(result.data, prefetchError(key)) + // TS infers non-nullable on a freestanding variable, but doesn't like to do + // it on a property. So we give it a hint + return result as SetNonNullable +} + +// what's up with [method, params]? +// +// https://react-query.tanstack.com/guides/queries +// +// The first arg to useQuery is a unique key, which can be a string, an object, +// or an array of those. The contents are tested with deep equality (not tricked +// by key order) to uniquely identify a request for caching purposes. For us, what +// uniquely identifies a request is the string name of the method and the params +// object. + +/** + * React Query query options helper that takes an API method, a params object, + * and (optionally) more RQ options. Returns a query options object. + */ +export const q = ( + f: (p: Params) => Promise>, + params: Params, + options: UseQueryOtherOptions = {} +) => + queryOptions({ + // method name first means we can invalidate all calls to this endpoint by + // invalidating [f.name] (see invalidateEndpoint) + queryKey: [f.name, params], + // no catch, let unexpected errors bubble up. note there is a signal param + // on queryFn that we could forward on to f(), but we are deliberately not + // doing that. we don't really care about canceling queries in flight, and + // it seems there are race conditions where something being unmounted on one + // page causes a query we need on the subsequent page to be canceled right + // in the middle of prefetching + queryFn: () => + f(params) + .then(handleResult(f.name)) + .then((result) => { + if (result.type === 'success') return result.data + throw result.data + }), + // In the case of 404s, let the error bubble up to the error boundary so + // we can say Not Found. If you need to allow a 404 and want it to show + // up as `error` state instead, pass `throwOnError: false` as an + // option from the calling component and it will override this + throwOnError: (err) => err.statusCode === 404, + ...options, + }) + +const ERRORS_ALLOWED = 'errors-allowed' + +/** + * Variant of `apiq` that allows error responses as a valid result, which + * importantly means they can be cached by RQ. This means we can prefetch an + * endpoint that might error and use this hook to retrieve the error result. + * + * Concretely, the difference from the usual query function is that we we + * don't throw if handleResult comes back with an error. We return the entire + * result object as a valid result. This means `data` has a type that includes + * the possibility of error, plus a discriminant to let us handle both sides + * properly in the calling code. + * + * We also use a special query key to distinguish these from normal API queries. + * If we hit a given endpoint twice on the same page, once the normal way and + * once with errors allowed, the results have different shapes, so we do not + * want to share the cache and mix them up. + */ +export const qErrorsAllowed = ( + f: (p: Params) => Promise>, + params: Params, + options: UseQueryOtherOptions> = {} +) => + queryOptions({ + // extra bit of key is important to distinguish from normal query. if we + // hit a given endpoint twice on the same page, once the normal way and + // once with errors allowed the responses have different shapes, so we do + // not want to share the cache and mix them up + queryKey: [f.name, params, ERRORS_ALLOWED], + queryFn: () => f(params).then(handleResult(f.name)), + // In the case of 404s, let the error bubble up to the error boundary so + // we can say Not Found. If you need to allow a 404 and want it to show + // up as `error` state instead, pass `throwOnError: false` as an + // option from the calling component and it will override this + throwOnError: (err) => err.statusCode === 404, + ...options, + }) + +// Unlike the query one, we don't need this to go through an options object +// because we are not calling the mutation in two spots and sharing the options +// +// The signal thing is a hack that lets us pass in a signal as part of the +// params because mutation functions don't take a nice thing you can pass a +// signal in. I tried doing this with MutationMeta +// https://tanstack.com/query/latest/docs/framework/react/typescript#registering-global-meta +// but mutate() and mutateAsync() don't take the full option set or a context +// object. You can only initalize the meta at the site of the useMutation call, +// which doesn't work for the image upload use case because the timeout signal +// needs to be initialized separately for each call. +export const useApiMutation = ( + f: (p: Params, fp: FetchParams) => Promise>, + options?: Omit< + // __signal bit makes it so you can pass a signal to mutate and mutateAsync. + // the underscores make it virtually impossible for this to conflict with an + // actual API field + UseMutationOptions, + 'mutationFn' + > +) => + useMutation({ + mutationFn: ({ __signal, ...params }) => + // Pretty safe cast: signal is an optional addition at the call site, not + // part of the original Params type. Removing it via destructuring gives + // us back Params, but TS can't prove Omit + // === Params structurally. + f(params as Params, { signal: __signal }) + .then(handleResult(f.name)) + .then((result) => { + if (result.type === 'success') return result.data + throw result.data + }), + // no catch, let unexpected errors bubble up + ...options, + }) diff --git a/app/api/hooks.ts b/app/api/hooks.ts deleted file mode 100644 index 6d9fba220..000000000 --- a/app/api/hooks.ts +++ /dev/null @@ -1,277 +0,0 @@ -/* - * 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 { - hashKey, - queryOptions, - useMutation, - type QueryKey, - type UseMutationOptions, - type UseQueryOptions, - type UseQueryResult, -} from '@tanstack/react-query' -import * as R from 'remeda' -import { type SetNonNullable } from 'type-fest' - -import { invariant } from '~/util/invariant' - -import type { ApiResult } from './__generated__/Api' -import { processServerError, type ApiError } from './errors' -import { navToLogin } from './nav-to-login' - -/* eslint-disable @typescript-eslint/no-explicit-any */ -type Params = F extends (p: infer P) => any ? P : never -type Result = F extends (p: any) => Promise> ? R : never - -export type ResultsPage = { items: TItem[]; nextPage?: string | null } - -type ApiClient = Record Promise>> -/* eslint-enable @typescript-eslint/no-explicit-any */ - -// method: keyof Api would be strictly more correct, but making it a string -// means we can call this directly in all the spots below instead of having to -// make it generic over Api, which requires passing it as an argument to -// getUseApiQuery, etc. This is fine because it is only being called inside -// functions where `method` is already required to be an API method. -const handleResult = - (method: string) => - (result: ApiResult) => { - if (result.type === 'success') return result.data - - // if logged out, hit /login to trigger login redirect - // Exception: 401 on password login POST needs to be handled in-page - if (result.response.status === 401 && method !== 'loginLocal') { - // TODO-usability: for background requests, a redirect to login without - // warning could come as a surprise to the user, especially because - // sometimes background requests are not directly triggered by a user - // action, e.g., polling or refetching when window regains focus - navToLogin({ includeCurrent: true }) - } - - const error = processServerError(method, result) - - // log to the console so it's there in case they open the dev tools, unlike - // network tab, which only records if dev tools are already open. but don't - // clutter test output - if (process.env.NODE_ENV !== 'test') { - const consolePage = window.location.pathname + window.location.search - // TODO: need to change oxide.ts to put the HTTP method on the result in - // order to log it here - console.error( - `More info about API ${error.statusCode || 'error'} on ${consolePage} - -API URL: ${result.response.url} -Request ID: ${error.requestId} -Error code: ${error.errorCode} -Error message: ${error.message.replace(/\n/g, '\n' + ' '.repeat('Error message: '.length))} -` - ) - } - - // we need to rethrow because that's how react-query knows it's an error - throw error - } - -/** - * `queryKey` and `queryFn` are always constructed by our helper hooks, so we - * only allow the rest of the options. - */ -type UseQueryOtherOptions = Omit< - UseQueryOptions, - 'queryKey' | 'queryFn' | 'initialData' -> - -export const getApiQueryOptions = - (api: A) => - ( - method: M, - params: Params, - options: UseQueryOtherOptions> = {} - ) => - queryOptions({ - queryKey: [method, params], - // no catch, let unexpected errors bubble up - queryFn: () => api[method](params).then(handleResult(method)), - // In the case of 404s, let the error bubble up to the error boundary so - // we can say Not Found. If you need to allow a 404 and want it to show - // up as `error` state instead, pass `useErrorBoundary: false` as an - // option from the calling component and it will override this - throwOnError: (err) => err.statusCode === 404, - ...options, - }) - -// Managed here instead of at the display layer so it can be built into the -// query options and shared between loader prefetch and QueryTable -export const PAGE_SIZE = 50 - -/** - * This primarily exists so we can have an object that encapsulates everything - * useQueryTable needs to know about a query. In particular, it needs the page - * size, and you can't pull that out of the query options object unless you - * stick it in `meta`, and then we don't have type safety. - */ -export type PaginatedQuery = { - optionsFn: ( - pageToken?: string - ) => UseQueryOptions & { queryKey: QueryKey } - pageSize: number -} - -/** - * This is the same as getApiQueryOptions except for two things: - * - * 1. We use a type constraint on the method key to ensure it can - * only be used with endpoints that return a `ResultsPage`. - * 2. Instead of returning the options directly, it returns a paginated - * query config object containing the page size and a function that - * takes `limit` and `pageToken` and merges them into the query params - * so that these can be passed in by `QueryTable`. - */ -export const getListQueryOptionsFn = - (api: A) => - < - M extends string & - { - // this helper can only be used with endpoints that return ResultsPage - [K in keyof A]: Result extends ResultsPage ? K : never - }[keyof A], - >( - method: M, - params: Params, - options: UseQueryOtherOptions> = {} - ): PaginatedQuery> => { - // We pull limit out of the query params rather than passing it in some - // other way so that there is exactly one way of specifying it. If we had - // some other way of doing it, and then you also passed it in as a query - // param, it would be hard to guess which takes precedence. (pathOr plays - // nice when the properties don't exist.) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const limit = R.pathOr(params as any, ['query', 'limit'], PAGE_SIZE) - return { - optionsFn: (pageToken?: string) => { - const newParams = { ...params, query: { ...params.query, limit, pageToken } } - return getApiQueryOptions(api)(method, newParams, { - ...options, - // identity function so current page sticks around while next loads - placeholderData: (x) => x, - }) - }, - pageSize: limit, - } - } - -const prefetchError = (key?: QueryKey) => - `Expected query to be prefetched. -Key: ${key ? hashKey(key) : ''} -Ensure the following: -• loader is called in routes.tsx and is running -• query matches in both the loader and the component -• request isn't erroring-out server-side (check the Networking tab) -• mock API endpoint is implemented in handlers.ts` - -/** - * Ensure a query result came from the cache by blowing up if `data` comes - * back undefined. - */ -export function ensurePrefetched( - result: UseQueryResult, - /** - * Optional because if we call this manually from a component like - * `ensure(useQuery(...))`, * we don't necessarily have access to the key. - */ - key?: QueryKey -) { - invariant(result.data, prefetchError(key)) - // TS infers non-nullable on a freestanding variable, but doesn't like to do - // it on a property. So we give it a hint - return result as SetNonNullable -} - -const ERRORS_ALLOWED = 'errors-allowed' - -/** Result that includes both success and error so it can be cached by RQ */ -type ErrorsAllowed = { type: 'success'; data: T } | { type: 'error'; data: E } - -export const getApiQueryOptionsErrorsAllowed = - (api: A) => - ( - method: M, - params: Params, - options: UseQueryOtherOptions, ApiError>> = {} - ) => - queryOptions({ - // extra bit of key is important to distinguish from normal query. if we - // hit a given endpoint twice on the same page, once the normal way and - // once with errors allowed the responses have different shapes, so we do - // not want to share the cache and mix them up - queryKey: [method, params, ERRORS_ALLOWED], - queryFn: ({ signal }) => - api[method](params, { signal }) - .then(handleResult(method)) - .then((data: Result) => ({ type: 'success' as const, data })) - .catch((data: ApiError) => ({ type: 'error' as const, data })), - ...options, - }) - -export const getUseApiMutation = - (api: A) => - ( - method: M, - options?: Omit< - UseMutationOptions, ApiError, Params & { signal?: AbortSignal }>, - 'mutationFn' - > - ) => - useMutation({ - mutationFn: ({ signal, ...params }) => - api[method](params, { signal }).then(handleResult(method)), - // no catch, let unexpected errors bubble up - ...options, - }) - -/* -1. what's up with [method, params]? - -https://react-query.tanstack.com/guides/queries - -The first arg to useQuery is a unique key, which can be a string, an object, -or an array of those. The contents are tested with deep equality (not tricked -by key order) to uniquely identify a request for caching purposes. For us, what -uniquely identifies a request is the string name of the method and the params -object. - -2. what's up with the types? - - A - api client object - M - api method name, i.e., a key on the client object - A[M] - api fetcher function like (p: Params) => Promise - Params - extract Params from the function - Result - extract Result from the function - -The difficulty is that we want full type safety, i.e., based on the method name -passed in, we want the typechecker to check the params and annotate the -response. PickByValue ensures we only call methods on the API object that follow -the (params) => Promise pattern. Then we use the inferred type of the -key (the method name) to enforce that params match the expected params on the -named method. Finally we use the Result helper to tell react-query what type to -put on the response data. - -3. why - - (api) => (method, params) => useQuery(..., api[method](params)) - - instead of - - const api = new Api() - (method, params) => useQuery(..., api[method](params)) - - i.e., why not use a closure for api? - -In order to infer the A type and enforce that M is the right kind of key such -that we can pull the params and response off and actually call api[method], api -needs to be an argument to the function too. -*/ diff --git a/app/api/index.ts b/app/api/index.ts index e58933ad8..66be84ea6 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -19,6 +19,5 @@ export * from './__generated__/Api' export type { ApiTypes } -export { ensurePrefetched, type PaginatedQuery, type ResultsPage } from './hooks' export type { ApiError } from './errors' export { navToLogin } from './nav-to-login' diff --git a/app/api/roles.ts b/app/api/roles.ts index 3dfb7ecdc..9c3ff07da 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -15,7 +15,7 @@ import { useMemo } from 'react' import * as R from 'remeda' import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generated__/Api' -import { apiq, usePrefetchedQuery } from './client' +import { api, q, usePrefetchedQuery } from './client' /** * Union of all the specific roles, which are all the same, which makes making @@ -98,8 +98,8 @@ export function useUserRows( ): UserAccessRow[] { // HACK: because the policy has no names, we are fetching ~all the users, // putting them in a dictionary, and adding the names to the rows - const { data: users } = usePrefetchedQuery(apiq('userList', {})) - const { data: groups } = usePrefetchedQuery(apiq('groupList', {})) + const { data: users } = usePrefetchedQuery(q(api.userList, {})) + const { data: groups } = usePrefetchedQuery(q(api.groupList, {})) return useMemo(() => { const userItems = users?.items || [] const groupItems = groups?.items || [] @@ -137,8 +137,8 @@ export type Actor = { * the given policy. */ export function useActorsNotInPolicy(policy: Policy): Actor[] { - const { data: users } = usePrefetchedQuery(apiq('userList', {})) - const { data: groups } = usePrefetchedQuery(apiq('groupList', {})) + const { data: users } = usePrefetchedQuery(q(api.userList, {})) + const { data: groups } = usePrefetchedQuery(q(api.groupList, {})) return useMemo(() => { // IDs are UUIDs, so no need to include identity type in set value to disambiguate const actorsInPolicy = new Set(policy?.roleAssignments.map((ra) => ra.identityId) || []) diff --git a/app/api/window.ts b/app/api/window.ts index c48bccbdb..dd186f2e5 100644 --- a/app/api/window.ts +++ b/app/api/window.ts @@ -43,7 +43,7 @@ if (typeof window !== 'undefined') { // @ts-expect-error window.oxql = { query: async (q: string) => { - const result = await api.methods.systemTimeseriesQuery({ body: { query: q } }) + const result = await api.systemTimeseriesQuery({ body: { query: q } }) const data = handleResult(result).tables logHeading(data.length + ' timeseries returned') for (const table of data) { @@ -60,7 +60,7 @@ if (typeof window !== 'undefined') { return data }, schemas: async (search?: string) => { - const result = await api.methods.systemTimeseriesSchemaList({ + const result = await api.systemTimeseriesSchemaList({ query: { limit: ALL_ISH }, }) const data = handleResult(result) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index e995d7725..1e0ca863a 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -9,7 +9,7 @@ import { useMemo } from 'react' import { useForm } from 'react-hook-form' -import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '~/api' +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' @@ -22,13 +22,13 @@ import { toIpPoolItem } from './form/fields/ip-pool-item' export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) => { const { project, instance } = useInstanceSelector() const { data: siloPools } = usePrefetchedQuery( - apiq('projectIpPoolList', { query: { limit: ALL_ISH } }) + q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) const defaultPool = useMemo( () => siloPools?.items.find((pool) => pool.isDefault), [siloPools] ) - const instanceEphemeralIpAttach = useApiMutation('instanceEphemeralIpAttach', { + const instanceEphemeralIpAttach = useApiMutation(api.instanceEphemeralIpAttach, { onSuccess(ephemeralIp) { queryClient.invalidateEndpoint('instanceExternalIpList') addToast(<>IP {ephemeralIp.ip} attached) // prettier-ignore diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx index 338b90800..fc592cfe5 100644 --- a/app/components/AttachFloatingIpModal.tsx +++ b/app/components/AttachFloatingIpModal.tsx @@ -10,7 +10,8 @@ import { useQuery } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { - apiqErrorsAllowed, + api, + qErrorsAllowed, queryClient, useApiMutation, type FloatingIp, @@ -26,7 +27,7 @@ import { ModalForm } from './form/ModalForm' function IpPoolName({ ipPoolId }: { ipPoolId: string }) { const { data: result } = useQuery( - apiqErrorsAllowed('projectIpPoolView', { path: { pool: ipPoolId } }) + qErrorsAllowed(api.projectIpPoolView, { path: { pool: ipPoolId } }) ) // As with IpPoolCell, this should never happen, but to be safe … if (!result || result.type === 'error') return null @@ -67,7 +68,7 @@ export const AttachFloatingIpModal = ({ instance: Instance onDismiss: () => void }) => { - const floatingIpAttach = useApiMutation('floatingIpAttach', { + const floatingIpAttach = useApiMutation(api.floatingIpAttach, { onSuccess(floatingIp) { queryClient.invalidateEndpoint('floatingIpList') queryClient.invalidateEndpoint('instanceExternalIpList') diff --git a/app/components/ErrorBoundary.tsx b/app/components/ErrorBoundary.tsx index 00cb74b89..b2f67d86d 100644 --- a/app/components/ErrorBoundary.tsx +++ b/app/components/ErrorBoundary.tsx @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { ErrorBoundary as BaseErrorBoundary } from 'react-error-boundary' import { useRouteError } from 'react-router' -import { apiq } from '~/api' +import { api, q } from '~/api' import { type ApiError } from '~/api/errors' import { Message } from '~/ui/lib/Message' import { links } from '~/util/links' @@ -42,9 +42,9 @@ const IdpMisconfig = () => ( function useDetectNoSiloRole(enabled: boolean): boolean { // this is kind of a hail mary, so if any of this goes wrong we need to ignore it const options = { enabled, throwOnError: false } - const { data: me } = useQuery(apiq('currentUserView', {}, options)) - const { data: myGroups } = useQuery(apiq('currentUserGroups', {}, options)) - const { data: siloPolicy } = useQuery(apiq('policyView', {}, options)) + const { data: me } = useQuery(q(api.currentUserView, {}, options)) + const { data: myGroups } = useQuery(q(api.currentUserGroups, {}, options)) + const { data: siloPolicy } = useQuery(q(api.policyView, {}, options)) if (!me || !myGroups || !siloPolicy) return false diff --git a/app/components/ErrorPage.tsx b/app/components/ErrorPage.tsx index ab831301f..a655a2571 100644 --- a/app/components/ErrorPage.tsx +++ b/app/components/ErrorPage.tsx @@ -10,7 +10,7 @@ import { Link } from 'react-router' import { Error12Icon, PrevArrow12Icon } from '@oxide/design-system/icons/react' -import { useApiMutation } from '~/api/client' +import { api, useApiMutation } from '~/api/client' import { navToLogin } from '~/api/nav-to-login' import { Button } from '~/ui/lib/Button' @@ -65,7 +65,7 @@ export function NotFound() { } export function SignOutButton({ className }: { className?: string }) { - const logout = useApiMutation('logout', { + const logout = useApiMutation(api.logout, { onSuccess: () => navToLogin({ includeCurrent: false }), }) return ( diff --git a/app/components/ExternalIps.tsx b/app/components/ExternalIps.tsx index de13a028b..45fc96331 100644 --- a/app/components/ExternalIps.tsx +++ b/app/components/ExternalIps.tsx @@ -10,7 +10,7 @@ import { useQuery } from '@tanstack/react-query' import { Link } from 'react-router' import * as R from 'remeda' -import { apiq, type ExternalIp } from '@oxide/api' +import { api, q, type ExternalIp } from '@oxide/api' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { CopyableIp } from '~/ui/lib/CopyableIp' @@ -25,7 +25,7 @@ export const orderIps = (ips: ExternalIp[]) => R.sortBy(ips, (a) => IP_ORDER[a.k export function ExternalIps({ project, instance }: PP.Instance) { const { data, isPending } = useQuery( - apiq('instanceExternalIpList', { path: { instance }, query: { project } }) + q(api.instanceExternalIpList, { path: { instance }, query: { project } }) ) if (isPending) return diff --git a/app/components/SystemMetric.tsx b/app/components/SystemMetric.tsx index 7c9e3e145..421db4dc3 100644 --- a/app/components/SystemMetric.tsx +++ b/app/components/SystemMetric.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { useMemo, useRef } from 'react' -import { apiq, synthesizeData, type ChartDatum, type SystemMetricName } from '@oxide/api' +import { api, q, synthesizeData, type ChartDatum, type SystemMetricName } from '@oxide/api' import { ChartContainer, ChartHeader, TimeSeriesChart } from './TimeSeriesChart' @@ -50,8 +50,8 @@ export function SiloMetric({ // TODO: we're only pulling the first page. Should we bump the cap to 10k? // Fetch multiple pages if 10k is not enough? That's a bit much. const inRange = useQuery( - apiq( - 'siloMetric', + q( + api.siloMetric, { path: { metricName }, query: { project, startTime, endTime, limit: 3000 } }, { placeholderData: (x) => x } ) @@ -59,8 +59,8 @@ export function SiloMetric({ // get last point before startTime to use as first point in graph const beforeStart = useQuery( - apiq( - 'siloMetric', + q( + api.siloMetric, { path: { metricName }, query: { project, endTime: startTime, ...staticParams } }, { placeholderData: (x) => x } ) @@ -119,8 +119,8 @@ export function SystemMetric({ // TODO: we're only pulling the first page. Should we bump the cap to 10k? // Fetch multiple pages if 10k is not enough? That's a bit much. const inRange = useQuery( - apiq( - 'systemMetric', + q( + api.systemMetric, { path: { metricName }, query: { silo, startTime, endTime, limit: 3000 } }, { placeholderData: (x) => x } ) @@ -128,8 +128,8 @@ export function SystemMetric({ // get last point before startTime to use as first point in graph const beforeStart = useQuery( - apiq( - 'systemMetric', + q( + api.systemMetric, { path: { metricName }, query: { silo, endTime: startTime, ...staticParams } }, { placeholderData: (x) => x } ) diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx index d4f29b417..b016c4784 100644 --- a/app/components/TopBar.tsx +++ b/app/components/TopBar.tsx @@ -8,7 +8,7 @@ import cn from 'classnames' import { Link } from 'react-router' -import { navToLogin, useApiMutation } from '@oxide/api' +import { api, navToLogin, useApiMutation } from '@oxide/api' import { Organization16Icon, Profile16Icon, @@ -125,7 +125,7 @@ function Breadcrumbs() { } function UserMenu() { - const logout = useApiMutation('logout', { + const logout = useApiMutation(api.logout, { onSuccess: () => navToLogin({ includeCurrent: false }), }) // fetch happens in loader wrapping all authed pages diff --git a/app/components/form/fields/SshKeysField.tsx b/app/components/form/fields/SshKeysField.tsx index 2582f2fc5..45603548a 100644 --- a/app/components/form/fields/SshKeysField.tsx +++ b/app/components/form/fields/SshKeysField.tsx @@ -8,7 +8,7 @@ import { useState } from 'react' import { useController, type Control } from 'react-hook-form' -import { apiq, usePrefetchedQuery } from '@oxide/api' +import { api, q, usePrefetchedQuery } from '@oxide/api' import { Key16Icon } from '@oxide/design-system/icons/react' import type { InstanceCreateInput } from '~/forms/instance-create' @@ -55,7 +55,7 @@ export function SshKeysField({ control: Control isSubmitting: boolean }) { - const keys = usePrefetchedQuery(apiq('currentUserSshKeyList', {})).data?.items || [] + const keys = usePrefetchedQuery(q(api.currentUserSshKeyList, {})).data?.items || [] const [showAddSshKey, setShowAddSshKey] = useState(false) const { diff --git a/app/components/form/fields/SubnetListbox.tsx b/app/components/form/fields/SubnetListbox.tsx index 2020e38ee..5d81c188b 100644 --- a/app/components/form/fields/SubnetListbox.tsx +++ b/app/components/form/fields/SubnetListbox.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { useWatch, type FieldPath, type FieldValues } from 'react-hook-form' -import { apiq } from '@oxide/api' +import { api, q } from '@oxide/api' import { useProjectSelector } from '~/hooks/use-params' @@ -42,8 +42,8 @@ export function SubnetListbox< // TODO: error handling other than fallback to empty list? const subnets = useQuery( - apiq( - 'vpcSubnetList', + q( + api.vpcSubnetList, { query: { ...projectSelector, vpc: vpcName } }, { enabled: vpcExists, throwOnError: false } ) diff --git a/app/components/form/fields/useItemsList.ts b/app/components/form/fields/useItemsList.ts index baab7508b..c6e3ed21b 100644 --- a/app/components/form/fields/useItemsList.ts +++ b/app/components/form/fields/useItemsList.ts @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' -import { apiq } from '@oxide/api' +import { api, q } from '@oxide/api' import { useVpcSelector } from '~/hooks/use-params' @@ -32,7 +32,7 @@ export function customRouterDataToForm(value: string | undefined | null): string export const useCustomRouterItems = () => { const vpcSelector = useVpcSelector() - const { data, isLoading } = useQuery(apiq('vpcRouterList', { query: vpcSelector })) + const { data, isLoading } = useQuery(q(api.vpcRouterList, { query: vpcSelector })) const routerItems = useMemo(() => { const items = (data?.items || []) diff --git a/app/components/oxql-metrics/OxqlMetric.tsx b/app/components/oxql-metrics/OxqlMetric.tsx index 5eba37cfb..18176da32 100644 --- a/app/components/oxql-metrics/OxqlMetric.tsx +++ b/app/components/oxql-metrics/OxqlMetric.tsx @@ -15,7 +15,7 @@ import { useQuery } from '@tanstack/react-query' import { Children, useMemo, useState, type ReactNode } from 'react' import type { LoaderFunctionArgs } from 'react-router' -import { apiq, OXQL_GROUP_BY_ERROR, queryClient } from '@oxide/api' +import { api, OXQL_GROUP_BY_ERROR, q, queryClient } from '@oxide/api' import { CopyCodeModal } from '~/components/CopyCode' import { MoreActionsMenu } from '~/components/MoreActionsMenu' @@ -38,7 +38,7 @@ import { export async function loader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await queryClient.prefetchQuery( - apiq('instanceView', { path: { instance }, query: { project } }) + q(api.instanceView, { path: { instance }, query: { project } }) ) return null } @@ -57,7 +57,7 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric error, isLoading, } = useQuery( - apiq('timeseriesQuery', { body: { query }, query: { project } }) + q(api.timeseriesQuery, { body: { query }, query: { project } }) // avoid graphs flashing blank while loading when you change the time // { placeholderData: (x) => x } ) diff --git a/app/forms/affinity-util.tsx b/app/forms/affinity-util.tsx index 32408f32a..5458be141 100644 --- a/app/forms/affinity-util.tsx +++ b/app/forms/affinity-util.tsx @@ -6,27 +6,30 @@ * Copyright Oxide Computer Company */ -import { apiq } from '~/api' +import { api, q } from '~/api' import { ALL_ISH } from '~/util/consts' import type * as PP from '~/util/path-params' export const instanceList = ({ project }: PP.Project) => - apiq('instanceList', { query: { project, limit: ALL_ISH } }) + q(api.instanceList, { query: { project, limit: ALL_ISH } }) export const antiAffinityGroupList = ({ project }: PP.Project) => - apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) + q(api.antiAffinityGroupList, { query: { project, limit: ALL_ISH } }) export const antiAffinityGroupView = ({ project, antiAffinityGroup, }: PP.AntiAffinityGroup) => - apiq('antiAffinityGroupView', { path: { antiAffinityGroup }, query: { project } }) + q(api.antiAffinityGroupView, { + path: { antiAffinityGroup }, + query: { project }, + }) export const antiAffinityGroupMemberList = ({ antiAffinityGroup, project, }: PP.AntiAffinityGroup) => - apiq('antiAffinityGroupMemberList', { + q(api.antiAffinityGroupMemberList, { path: { antiAffinityGroup }, // member limit in DB is currently 32, so pagination isn't needed query: { project, limit: ALL_ISH }, diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx index 119888f92..c464249fd 100644 --- a/app/forms/anti-affinity-group-create.tsx +++ b/app/forms/anti-affinity-group-create.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { queryClient, useApiMutation, type AntiAffinityGroupCreate } from '@oxide/api' +import { api, queryClient, useApiMutation, type AntiAffinityGroupCreate } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -35,7 +35,7 @@ export default function CreateAntiAffinityGroupForm() { const navigate = useNavigate() - const createAntiAffinityGroup = useApiMutation('antiAffinityGroupCreate', { + const createAntiAffinityGroup = useApiMutation(api.antiAffinityGroupCreate, { onSuccess(antiAffinityGroup) { queryClient.invalidateEndpoint('antiAffinityGroupList') navigate(pb.antiAffinityGroup({ project, antiAffinityGroup: antiAffinityGroup.name })) diff --git a/app/forms/anti-affinity-group-edit.tsx b/app/forms/anti-affinity-group-edit.tsx index e098c05f6..7c40cb797 100644 --- a/app/forms/anti-affinity-group-edit.tsx +++ b/app/forms/anti-affinity-group-edit.tsx @@ -10,6 +10,7 @@ import { useNavigate, type LoaderFunctionArgs } from 'react-router' import * as R from 'remeda' import { + api, queryClient, useApiMutation, usePrefetchedQuery, @@ -43,7 +44,7 @@ export default function EditAntiAffintyGroupForm() { const navigate = useNavigate() - const editAntiAffinityGroup = useApiMutation('antiAffinityGroupUpdate', { + const editAntiAffinityGroup = useApiMutation(api.antiAffinityGroupUpdate, { onSuccess(updatedGroup) { queryClient.invalidateEndpoint('antiAffinityGroupView') queryClient.invalidateEndpoint('antiAffinityGroupList') diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index 3f74f7057..e8987c70f 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -9,7 +9,7 @@ import { useId } from 'react' import { useForm } from 'react-hook-form' -import { instanceCan, queryClient, useApiMutation, type Instance } from '~/api' +import { api, instanceCan, queryClient, useApiMutation, type Instance } from '~/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { HL } from '~/components/HL' import { useAntiAffinityGroupSelector } from '~/hooks/use-params' @@ -30,21 +30,24 @@ export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }: const form = useForm({ defaultValues }) const formId = useId() - const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', { - onSuccess(_data, variables) { - onDismiss() - queryClient.invalidateEndpoint('antiAffinityGroupMemberList') - queryClient.invalidateEndpoint('instanceAntiAffinityGroupList') - addToast(<>Instance {variables.path.instance} added to anti-affinity group {antiAffinityGroup}) // prettier-ignore - }, - onError(error) { - addToast({ - title: 'Failed to add instance to group', - content: error.message, - variant: 'error', - }) - }, - }) + const { mutateAsync: addMember } = useApiMutation( + api.antiAffinityGroupMemberInstanceAdd, + { + onSuccess(_data, variables) { + onDismiss() + queryClient.invalidateEndpoint('antiAffinityGroupMemberList') + queryClient.invalidateEndpoint('instanceAntiAffinityGroupList') + addToast(<>Instance {variables.path.instance} added to anti-affinity group {antiAffinityGroup}) // prettier-ignore + }, + onError(error) { + addToast({ + title: 'Failed to add instance to group', + content: error.message, + variant: 'error', + }) + }, + } + ) const onSubmit = form.handleSubmit(({ instance }) => { addMember({ diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx index c0bbb7bcd..5aec89ade 100644 --- a/app/forms/disk-attach.tsx +++ b/app/forms/disk-attach.tsx @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { useForm } from 'react-hook-form' -import { apiq, type ApiError } from '@oxide/api' +import { api, q, type ApiError } from '@oxide/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { ModalForm } from '~/components/form/ModalForm' @@ -42,7 +42,7 @@ export function AttachDiskModalForm({ const { project } = useProjectSelector() const { data, isPending } = useQuery( - apiq('diskList', { query: { project, limit: ALL_ISH } }) + q(api.diskList, { query: { project, limit: ALL_ISH } }) ) const detachedDisks = useMemo( () => diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 314a78c92..0530a8f86 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -11,7 +11,8 @@ import { useMemo } from 'react' import { useController, useForm, type Control } from 'react-hook-form' import { - apiq, + api, + q, queryClient, useApiMutation, type BlockSize, @@ -70,7 +71,7 @@ export function CreateDiskSideModalForm({ onDismiss, unavailableDiskNames = [], }: CreateSideModalFormProps) { - const createDisk = useApiMutation('diskCreate', { + const createDisk = useApiMutation(api.diskCreate, { onSuccess(data) { queryClient.invalidateEndpoint('diskList') addToast(<>Disk {data.name} created) // prettier-ignore @@ -81,8 +82,8 @@ export function CreateDiskSideModalForm({ const form = useForm({ defaultValues }) const { project } = useProjectSelector() - const projectImages = useQuery(apiq('imageList', { query: { project } })) - const siloImages = useQuery(apiq('imageList', {})) + const projectImages = useQuery(q(api.imageList, { query: { project } })) + const siloImages = useQuery(q(api.imageList, {})) // put project images first because if there are any, there probably aren't // very many and they're probably relevant @@ -92,7 +93,7 @@ export function CreateDiskSideModalForm({ ) const areImagesLoading = projectImages.isPending || siloImages.isPending - const snapshotsQuery = useQuery(apiq('snapshotList', { query: { project } })) + const snapshotsQuery = useQuery(q(api.snapshotList, { query: { project } })) const snapshots = snapshotsQuery.data?.items || [] // validate disk source size @@ -235,7 +236,7 @@ const DiskSourceField = ({ const DiskNameFromId = ({ disk }: { disk: string }) => { const { data, isPending, isError } = useQuery( - apiq('diskView', { path: { disk } }, { throwOnError: false }) + q(api.diskView, { path: { disk } }, { throwOnError: false }) ) if (isPending || isError) return null @@ -244,7 +245,7 @@ const DiskNameFromId = ({ disk }: { disk: string }) => { const SnapshotSelectField = ({ control }: { control: Control }) => { const { project } = useProjectSelector() - const snapshotsQuery = useQuery(apiq('snapshotList', { query: { project } })) + const snapshotsQuery = useQuery(q(api.snapshotList, { query: { project } })) const snapshots = snapshotsQuery.data?.items || [] const diskSizeField = useController({ control, name: 'size' }).field diff --git a/app/forms/firewall-rules-common.tsx b/app/forms/firewall-rules-common.tsx index 29d52315f..9b022758a 100644 --- a/app/forms/firewall-rules-common.tsx +++ b/app/forms/firewall-rules-common.tsx @@ -12,7 +12,8 @@ import { useController, useForm, type Control } from 'react-hook-form' import { Badge } from '@oxide/design-system/ui' import { - apiq, + api, + q, usePrefetchedQuery, type ApiError, type Instance, @@ -110,13 +111,13 @@ const TargetAndHostFilterSubform = ({ // prefetchedApiQueries below are prefetched in firewall-rules-create and -edit const { data: { items: instances }, - } = usePrefetchedQuery(apiq('instanceList', { query: { project, limit: ALL_ISH } })) + } = usePrefetchedQuery(q(api.instanceList, { query: { project, limit: ALL_ISH } })) const { data: { items: vpcs }, - } = usePrefetchedQuery(apiq('vpcList', { query: { project, limit: ALL_ISH } })) + } = usePrefetchedQuery(q(api.vpcList, { query: { project, limit: ALL_ISH } })) const { data: { items: vpcSubnets }, - } = usePrefetchedQuery(apiq('vpcSubnetList', { query: { project, vpc, limit: ALL_ISH } })) + } = usePrefetchedQuery(q(api.vpcSubnetList, { query: { project, vpc, limit: ALL_ISH } })) const subform = useForm({ defaultValues: targetAndHostDefaultValues }) const field = useController({ name: `${sectionType}s`, control }).field diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index 09c5110e3..7635bb5c7 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -9,8 +9,9 @@ import { useForm } from 'react-hook-form' import { useNavigate, useParams, type LoaderFunctionArgs } from 'react-router' import { - apiq, + api, firewallRuleGetToPut, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -61,11 +62,11 @@ const ruleToValues = (rule: VpcFirewallRule): FirewallRuleValues => ({ export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc } = getVpcSelector(params) await Promise.all([ - queryClient.prefetchQuery(apiq('vpcFirewallRulesView', { query: { project, vpc } })), - queryClient.prefetchQuery(apiq('instanceList', { query: { project, limit: ALL_ISH } })), - queryClient.prefetchQuery(apiq('vpcList', { query: { project, limit: ALL_ISH } })), + queryClient.prefetchQuery(q(api.vpcFirewallRulesView, { query: { project, vpc } })), + queryClient.prefetchQuery(q(api.instanceList, { query: { project, limit: ALL_ISH } })), + queryClient.prefetchQuery(q(api.vpcList, { query: { project, limit: ALL_ISH } })), queryClient.prefetchQuery( - apiq('vpcSubnetList', { query: { project, vpc, limit: ALL_ISH } }) + q(api.vpcSubnetList, { query: { project, vpc, limit: ALL_ISH } }) ), ]) @@ -78,7 +79,7 @@ export default function CreateFirewallRuleForm() { const navigate = useNavigate() const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector)) - const updateRules = useApiMutation('vpcFirewallRulesUpdate', { + const updateRules = useApiMutation(api.vpcFirewallRulesUpdate, { onSuccess(updatedRules) { const newRule = updatedRules.rules[updatedRules.rules.length - 1] queryClient.invalidateEndpoint('vpcFirewallRulesView') @@ -87,7 +88,7 @@ export default function CreateFirewallRuleForm() { }, }) - const { data } = usePrefetchedQuery(apiq('vpcFirewallRulesView', { query: vpcSelector })) + const { data } = usePrefetchedQuery(q(api.vpcFirewallRulesView, { query: vpcSelector })) const existingRules = data.rules // The :rule path param is optional. If it is present, we are creating a diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 8c8e9dbf5..e3cd9f7a8 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -9,8 +9,9 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, + api, firewallRuleGetToPut, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -39,11 +40,11 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc, rule } = getFirewallRuleSelector(params) const [firewallRules] = await Promise.all([ - queryClient.fetchQuery(apiq('vpcFirewallRulesView', { query: { project, vpc } })), - queryClient.prefetchQuery(apiq('instanceList', { query: { project, limit: ALL_ISH } })), - queryClient.prefetchQuery(apiq('vpcList', { query: { project, limit: ALL_ISH } })), + queryClient.fetchQuery(q(api.vpcFirewallRulesView, { query: { project, vpc } })), + queryClient.prefetchQuery(q(api.instanceList, { query: { project, limit: ALL_ISH } })), + queryClient.prefetchQuery(q(api.vpcList, { query: { project, limit: ALL_ISH } })), queryClient.prefetchQuery( - apiq('vpcSubnetList', { query: { project, vpc, limit: ALL_ISH } }) + q(api.vpcSubnetList, { query: { project, vpc, limit: ALL_ISH } }) ), ]) @@ -58,7 +59,7 @@ export default function EditFirewallRuleForm() { const vpcSelector = useVpcSelector() const { data: firewallRules } = usePrefetchedQuery( - apiq('vpcFirewallRulesView', { query: { project, vpc } }) + q(api.vpcFirewallRulesView, { query: { project, vpc } }) ) const originalRule = firewallRules.rules.find((r) => r.name === rule) @@ -69,7 +70,7 @@ export default function EditFirewallRuleForm() { const navigate = useNavigate() const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector)) - const updateRules = useApiMutation('vpcFirewallRulesUpdate', { + const updateRules = useApiMutation(api.vpcFirewallRulesUpdate, { 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 diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index db441a1fd..c90f83619 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -11,7 +11,7 @@ import { useState } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { apiq, queryClient, useApiMutation, type FloatingIpCreate } from '@oxide/api' +import { api, q, queryClient, useApiMutation, type FloatingIpCreate } from '@oxide/api' import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -39,13 +39,13 @@ export default function CreateFloatingIpSideModalForm() { // Fetch 1000 to we can be sure to get them all. Don't bother prefetching // because the list is hidden under the Advanced accordion. const { data: allPools } = useQuery( - apiq('projectIpPoolList', { query: { limit: ALL_ISH } }) + q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) const projectSelector = useProjectSelector() const navigate = useNavigate() - const createFloatingIp = useApiMutation('floatingIpCreate', { + const createFloatingIp = useApiMutation(api.floatingIpCreate, { onSuccess(floatingIp) { queryClient.invalidateEndpoint('floatingIpList') queryClient.invalidateEndpoint('ipPoolUtilizationView') diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index e9cc9c80c..8ad6b9e9e 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -9,8 +9,9 @@ import { useForm } from 'react-hook-form' import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, + api, getListQFn, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -32,9 +33,9 @@ import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' const floatingIpView = ({ project, floatingIp }: PP.FloatingIp) => - apiq('floatingIpView', { path: { floatingIp }, query: { project } }) + q(api.floatingIpView, { path: { floatingIp }, query: { project } }) const instanceList = (project: string) => - getListQFn('instanceList', { query: { project, limit: ALL_ISH } }) + getListQFn(api.instanceList, { query: { project, limit: ALL_ISH } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getFloatingIpSelector(params) @@ -60,7 +61,7 @@ export default function EditFloatingIpSideModalForm() { ) const instanceName = instances.items.find((i) => i.id === floatingIp.instanceId)?.name - const editFloatingIp = useApiMutation('floatingIpUpdate', { + const editFloatingIp = useApiMutation(api.floatingIpUpdate, { onSuccess(_floatingIp) { queryClient.invalidateEndpoint('floatingIpList') addToast(<>Floating IP {_floatingIp.name} updated) // prettier-ignore diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index cfacf2351..0c7e2bc84 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -9,7 +9,7 @@ import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { queryClient, useApiMutation } from '@oxide/api' +import { api, queryClient, useApiMutation } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { FileField } from '~/components/form/fields/FileField' @@ -60,7 +60,7 @@ export default function CreateIdpSideModalForm() { const onDismiss = () => navigate(pb.silo({ silo })) - const createIdp = useApiMutation('samlIdentityProviderCreate', { + const createIdp = useApiMutation(api.samlIdentityProviderCreate, { onSuccess(idp) { queryClient.invalidateEndpoint('siloIdentityProviderList') addToast(<>IdP {idp.name} created) // prettier-ignore diff --git a/app/forms/idp/edit.tsx b/app/forms/idp/edit.tsx index 5b7e82f20..eea0fbae6 100644 --- a/app/forms/idp/edit.tsx +++ b/app/forms/idp/edit.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { Access16Icon } from '@oxide/design-system/icons/react' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -25,7 +25,7 @@ import { pb } from '~/util/path-builder' export async function clientLoader({ params }: LoaderFunctionArgs) { const { silo, provider } = getIdpSelector(params) await queryClient.prefetchQuery( - apiq('samlIdentityProviderView', { path: { provider }, query: { silo } }) + q(api.samlIdentityProviderView, { path: { provider }, query: { silo } }) ) return null } @@ -35,7 +35,7 @@ export const handle = titleCrumb('Edit Identity Provider') export default function EditIdpSideModalForm() { const { silo, provider } = useIdpSelector() const { data: idp } = usePrefetchedQuery( - apiq('samlIdentityProviderView', { path: { provider }, query: { silo } }) + q(api.samlIdentityProviderView, { path: { provider }, query: { silo } }) ) const navigate = useNavigate() diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx index 77a33e157..02c15927e 100644 --- a/app/forms/image-from-snapshot.tsx +++ b/app/forms/image-from-snapshot.tsx @@ -10,7 +10,8 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, + api, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -37,7 +38,7 @@ const defaultValues: Omit = { } const snapshotView = ({ project, snapshot }: PP.Snapshot) => - apiq('snapshotView', { path: { snapshot }, query: { project } }) + q(api.snapshotView, { path: { snapshot }, query: { project } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, snapshot } = getProjectSnapshotSelector(params) @@ -54,7 +55,7 @@ export default function CreateImageFromSnapshotSideModalForm() { const onDismiss = () => navigate(pb.snapshots({ project })) - const createImage = useApiMutation('imageCreate', { + const createImage = useApiMutation(api.imageCreate, { onSuccess(image) { queryClient.invalidateEndpoint('imageList') addToast(<>Image {image.name} created) // prettier-ignore diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index 3131f4513..68a352a62 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -15,7 +15,8 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' import { - apiq, + api, + q, queryClient, useApiMutation, type ApiError, @@ -208,25 +209,25 @@ export default function ImageCreate() { // done with everything, ready to close the modal const [allDone, setAllDone] = useState(false) - const createDisk = useApiMutation('diskCreate') - const startImport = useApiMutation('diskBulkWriteImportStart') + const createDisk = useApiMutation(api.diskCreate) + const startImport = useApiMutation(api.diskBulkWriteImportStart) // gcTime: 0 prevents the mutation cache from holding onto all the chunks for // 5 minutes. It can be a ton of memory. To be honest, I don't even understand // why the mutation cache exists. It's not like the query cache, which dedupes // identical queries made around the same time. // https://tanstack.com/query/v5/docs/reference/MutationCache - const uploadChunk = useApiMutation('diskBulkWriteImport', { gcTime: 0 }) + const uploadChunk = useApiMutation(api.diskBulkWriteImport, { gcTime: 0 }) // synthetic state for upload step because it consists of multiple requests const [syntheticUploadState, setSyntheticUploadState] = useState(initSyntheticState) - const stopImport = useApiMutation('diskBulkWriteImportStop') - const finalizeDisk = useApiMutation('diskFinalizeImport') - const createImage = useApiMutation('imageCreate') - const deleteDisk = useApiMutation('diskDelete') - const deleteSnapshot = useApiMutation('snapshotDelete') + const stopImport = useApiMutation(api.diskBulkWriteImportStop) + const finalizeDisk = useApiMutation(api.diskFinalizeImport) + const createImage = useApiMutation(api.imageCreate) + const deleteDisk = useApiMutation(api.diskDelete) + const deleteSnapshot = useApiMutation(api.snapshotDelete) // TODO: Distinguish cleanup mutations being called after successful run vs. // due to error. In the former case, they have their own steps to highlight as @@ -244,17 +245,17 @@ export default function ImageCreate() { ] // separate so we can distinguish between cleanup due to error vs. cleanup after success - const stopImportCleanup = useApiMutation('diskBulkWriteImportStop') - const finalizeDiskCleanup = useApiMutation('diskFinalizeImport') + const stopImportCleanup = useApiMutation(api.diskBulkWriteImportStop) + const finalizeDiskCleanup = useApiMutation(api.diskFinalizeImport) // in production these invalidations are unlikely to matter, but they help a // lot in the tests when we check the disk list after canceling to make sure // the temp resources got deleted - const deleteDiskCleanup = useApiMutation('diskDelete', { + const deleteDiskCleanup = useApiMutation(api.diskDelete, { onSuccess() { queryClient.invalidateEndpoint('diskList') }, }) - const deleteSnapshotCleanup = useApiMutation('snapshotDelete', { + const deleteSnapshotCleanup = useApiMutation(api.snapshotDelete, { onSuccess() { queryClient.invalidateEndpoint('snapshotList') }, @@ -328,7 +329,7 @@ export default function ImageCreate() { if (disk.current) { // we won't be able to delete the disk unless it's out of import mode const path = { disk: disk.current.id } - const freshDisk = await queryClient.fetchQuery(apiq('diskView', { path })) + const freshDisk = await queryClient.fetchQuery(q(api.diskView, { path })) const diskState = freshDisk.state.state if (diskState === 'importing_from_bulk_writes') { await stopImportCleanup.mutateAsync({ path }) @@ -410,7 +411,7 @@ export default function ImageCreate() { path, body: { offset, base64EncodedData }, // use both the abort signal for the whole upload and a per-request timeout - signal: anySignal([ + __signal: anySignal([ AbortSignal.timeout(30000), abortController.current?.signal, ]), @@ -456,7 +457,10 @@ export default function ImageCreate() { // diskFinalizeImport does not return the snapshot, but create image // requires an ID snapshot.current = await queryClient.fetchQuery( - apiq('snapshotView', { path: { snapshot: snapshotName }, query: { project } }) + q(api.snapshotView, { + path: { snapshot: snapshotName }, + query: { project }, + }) ) abortController.current?.signal.throwIfAborted() @@ -509,7 +513,10 @@ export default function ImageCreate() { // check that image name isn't taken before starting the whole thing const image = await queryClient .fetchQuery( - apiq('imageView', { path: { image: values.imageName }, query: { project } }) + q(api.imageView, { + path: { image: values.imageName }, + query: { project }, + }) ) .catch((e) => { // eat a 404 since that's what we want. anything else should still blow up diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index bd09b1cb7..6246a655e 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -12,11 +12,12 @@ import { useNavigate, type LoaderFunctionArgs } from 'react-router' import type { SetRequired } from 'type-fest' import { - apiq, + api, diskCan, genName, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -161,13 +162,13 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) await Promise.all([ // fetch both project and silo images - queryClient.prefetchQuery(apiq('imageList', { query: { project } })), - queryClient.prefetchQuery(apiq('imageList', {})), - queryClient.prefetchQuery(apiq('diskList', { query: { project, limit: ALL_ISH } })), - queryClient.prefetchQuery(apiq('currentUserSshKeyList', {})), - queryClient.prefetchQuery(apiq('projectIpPoolList', { query: { limit: ALL_ISH } })), + queryClient.prefetchQuery(q(api.imageList, { query: { project } })), + queryClient.prefetchQuery(q(api.imageList, {})), + queryClient.prefetchQuery(q(api.diskList, { query: { project, limit: ALL_ISH } })), + queryClient.prefetchQuery(q(api.currentUserSshKeyList, {})), + queryClient.prefetchQuery(q(api.projectIpPoolList, { query: { limit: ALL_ISH } })), queryClient.prefetchQuery( - apiq('floatingIpList', { query: { project, limit: ALL_ISH } }) + q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ), ]) return null @@ -180,12 +181,12 @@ export default function CreateInstanceForm() { const { project } = useProjectSelector() const navigate = useNavigate() - const createInstance = useApiMutation('instanceCreate', { + const createInstance = useApiMutation(api.instanceCreate, { onSuccess(instance) { // refetch list of instances queryClient.invalidateEndpoint('instanceList') // avoid the instance fetch when the instance page loads since we have the data - const instanceView = apiq('instanceView', { + const instanceView = q(api.instanceView, { path: { instance: instance.name }, query: { project }, }) @@ -195,24 +196,24 @@ export default function CreateInstanceForm() { }, }) - const siloImages = usePrefetchedQuery(apiq('imageList', {})).data.items - const projectImages = usePrefetchedQuery(apiq('imageList', { query: { project } })).data + const siloImages = usePrefetchedQuery(q(api.imageList, {})).data.items + const projectImages = usePrefetchedQuery(q(api.imageList, { query: { project } })).data .items const allImages = [...siloImages, ...projectImages] const defaultImage = allImages[0] const allDisks = usePrefetchedQuery( - apiq('diskList', { query: { project, limit: ALL_ISH } }) + q(api.diskList, { query: { project, limit: ALL_ISH } }) ).data.items const disks = useMemo(() => toComboboxItems(allDisks.filter(diskCan.attach)), [allDisks]) - const { data: sshKeys } = usePrefetchedQuery(apiq('currentUserSshKeyList', {})) + const { data: sshKeys } = usePrefetchedQuery(q(api.currentUserSshKeyList, {})) const allKeys = useMemo(() => sshKeys.items.map((key) => key.id), [sshKeys]) // projectIpPoolList fetches the pools linked to the current silo const { data: siloPools } = usePrefetchedQuery( - apiq('projectIpPoolList', { query: { limit: ALL_ISH } }) + q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) const defaultPool = useMemo( () => (siloPools ? siloPools.items.find((p) => p.isDefault)?.name : undefined), @@ -638,7 +639,7 @@ const AdvancedAccordion = ({ const { project } = useProjectSelector() const { data: floatingIpList } = usePrefetchedQuery( - apiq('floatingIpList', { query: { project, limit: ALL_ISH } }) + q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ) // Filter out the IPs that are already attached to an instance diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index 0b0c2e716..84aaca3a0 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' import type { SetRequired } from 'type-fest' -import { queryClient, useApiMutation, type IpPoolCreate } from '@oxide/api' +import { api, queryClient, useApiMutation, type IpPoolCreate } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -41,7 +41,7 @@ export default function CreateIpPoolSideModalForm() { const onDismiss = () => navigate(pb.ipPools()) - const createPool = useApiMutation('ipPoolCreate', { + const createPool = useApiMutation(api.ipPoolCreate, { onSuccess(_pool) { queryClient.invalidateEndpoint('ipPoolList') addToast(<>IP pool {_pool.name} created) // prettier-ignore diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index 86babdf6e..564abca6e 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -22,7 +22,7 @@ import type * as PP from '~/util/path-params' import { IpPoolVisibilityMessage } from './ip-pool-create' -const ipPoolView = ({ pool }: PP.IpPool) => apiq('ipPoolView', { path: { pool } }) +const ipPoolView = ({ pool }: PP.IpPool) => q(api.ipPoolView, { path: { pool } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getIpPoolSelector(params) @@ -40,7 +40,7 @@ export default function EditIpPoolSideModalForm() { const form = useForm({ defaultValues: pool }) - const editPool = useApiMutation('ipPoolUpdate', { + const editPool = useApiMutation(api.ipPoolUpdate, { onSuccess(updatedPool) { queryClient.invalidateEndpoint('ipPoolList') navigate(pb.ipPool({ pool: updatedPool.name })) diff --git a/app/forms/ip-pool-range-add.tsx b/app/forms/ip-pool-range-add.tsx index 6f1aa98d4..48a370d54 100644 --- a/app/forms/ip-pool-range-add.tsx +++ b/app/forms/ip-pool-range-add.tsx @@ -8,7 +8,7 @@ import { useForm, type FieldErrors } from 'react-hook-form' import { useNavigate } from 'react-router' -import { queryClient, useApiMutation, type IpRange } from '@oxide/api' +import { api, queryClient, useApiMutation, type IpRange } from '@oxide/api' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' @@ -68,7 +68,7 @@ export default function IpPoolAddRange() { const onDismiss = () => navigate(pb.ipPool({ pool })) - const addRange = useApiMutation('ipPoolRangeAdd', { + const addRange = useApiMutation(api.ipPoolRangeAdd, { onSuccess(_range) { // refetch list of projects in sidebar queryClient.invalidateEndpoint('ipPoolRangeList') diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index c3ac8b7e5..b4fc54b74 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -10,7 +10,7 @@ import { useMemo } from 'react' import { useForm } from 'react-hook-form' import type { SetNonNullable, SetRequired } from 'type-fest' -import { apiq, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxide/api' +import { api, q, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { ListboxField } from '~/components/form/fields/ListboxField' @@ -48,7 +48,7 @@ export function CreateNetworkInterfaceForm({ }: CreateNetworkInterfaceFormProps) { const projectSelector = useProjectSelector() - const { data: vpcsData } = useQuery(apiq('vpcList', { query: projectSelector })) + const { data: vpcsData } = useQuery(q(api.vpcList, { query: projectSelector })) const vpcs = useMemo(() => vpcsData?.items || [], [vpcsData]) const form = useForm({ defaultValues }) diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index de5e9750f..66e6d6e56 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -10,6 +10,7 @@ import { useForm } from 'react-hook-form' import * as R from 'remeda' import { + api, queryClient, useApiMutation, type InstanceNetworkInterface, @@ -42,7 +43,7 @@ export function EditNetworkInterfaceForm({ }: EditNetworkInterfaceFormProps) { const instanceSelector = useInstanceSelector() - const editNetworkInterface = useApiMutation('instanceNetworkInterfaceUpdate', { + const editNetworkInterface = useApiMutation(api.instanceNetworkInterfaceUpdate, { onSuccess(nic) { queryClient.invalidateEndpoint('instanceNetworkInterfaceList') addToast(<>Network interface {nic.name} updated) // prettier-ignore diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index b4a2081e2..270fa9844 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -7,7 +7,13 @@ */ import { useForm } from 'react-hook-form' -import { queryClient, updateRole, useActorsNotInPolicy, useApiMutation } from '@oxide/api' +import { + api, + queryClient, + updateRole, + useActorsNotInPolicy, + useApiMutation, +} from '@oxide/api' import { Access16Icon } from '@oxide/design-system/icons/react' import { ListboxField } from '~/components/form/fields/ListboxField' @@ -29,7 +35,7 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa const actors = useActorsNotInPolicy(policy) - const updatePolicy = useApiMutation('projectPolicyUpdate', { + const updatePolicy = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('projectPolicyView') // We don't have the name of the user or group, so we'll just have a generic message @@ -82,7 +88,7 @@ export function ProjectAccessEditUserSideModal({ }: EditRoleModalProps) { const { project } = useProjectSelector() - const updatePolicy = useApiMutation('projectPolicyUpdate', { + const updatePolicy = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('projectPolicyView') addToast({ content: 'Role updated' }) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 81bdc2a37..170a9f9b3 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { apiq, queryClient, useApiMutation, type ProjectCreate } from '@oxide/api' +import { api, q, queryClient, useApiMutation, type ProjectCreate } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -30,12 +30,12 @@ export default function ProjectCreateSideModalForm() { const onDismiss = () => navigate(pb.projects()) - const createProject = useApiMutation('projectCreate', { + const createProject = useApiMutation(api.projectCreate, { onSuccess(project) { // refetch list of projects in sidebar queryClient.invalidateEndpoint('projectList') // avoid the project fetch when the project page loads since we have the data - const projectView = apiq('projectView', { path: { project: project.name } }) + const projectView = q(api.projectView, { path: { project: project.name } }) queryClient.setQueryData(projectView.queryKey, project) 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 11db6968d..3ec2ad456 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -20,7 +20,7 @@ import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' -const projectView = ({ project }: PP.Project) => apiq('projectView', { path: { project } }) +const projectView = ({ project }: PP.Project) => q(api.projectView, { path: { project } }) export const handle = titleCrumb('Edit project') @@ -39,7 +39,7 @@ export default function EditProjectSideModalForm() { const { data: project } = usePrefetchedQuery(projectView(projectSelector)) - const editProject = useApiMutation('projectUpdate', { + const editProject = useApiMutation(api.projectUpdate, { onSuccess(project) { // refetch list of projects in sidebar queryClient.invalidateEndpoint('projectList') diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index 27bdeb00d..150a9ca57 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -7,7 +7,13 @@ */ import { useForm } from 'react-hook-form' -import { queryClient, updateRole, useActorsNotInPolicy, useApiMutation } from '@oxide/api' +import { + api, + queryClient, + updateRole, + useActorsNotInPolicy, + useApiMutation, +} from '@oxide/api' import { Access16Icon } from '@oxide/design-system/icons/react' import { ListboxField } from '~/components/form/fields/ListboxField' @@ -25,7 +31,7 @@ import { export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) { const actors = useActorsNotInPolicy(policy) - const updatePolicy = useApiMutation('policyUpdate', { + const updatePolicy = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') onDismiss() @@ -74,7 +80,7 @@ export function SiloAccessEditUserSideModal({ policy, defaultValues, }: EditRoleModalProps) { - const updatePolicy = useApiMutation('policyUpdate', { + const updatePolicy = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') onDismiss() diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index bd077f8da..e1aadd952 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -10,7 +10,7 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' import { match } from 'ts-pattern' -import { apiq, queryClient, useApiMutation, type SiloCreate } from '@oxide/api' +import { api, q, queryClient, useApiMutation, type SiloCreate } from '@oxide/api' import { CheckboxField } from '~/components/form/fields/CheckboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -57,10 +57,10 @@ export default function CreateSiloSideModalForm() { const onDismiss = () => navigate(pb.silos()) - const createSilo = useApiMutation('siloCreate', { + const createSilo = useApiMutation(api.siloCreate, { onSuccess(silo) { queryClient.invalidateEndpoint('siloList') - const siloView = apiq('siloView', { path: { silo: silo.name } }) + const siloView = q(api.siloView, { path: { silo: silo.name } }) queryClient.setQueryData(siloView.queryKey, silo) addToast(<>Silo {silo.name} created) // prettier-ignore onDismiss() diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx index 451857939..b34f18a95 100644 --- a/app/forms/snapshot-create.tsx +++ b/app/forms/snapshot-create.tsx @@ -10,7 +10,14 @@ import { useMemo } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { apiq, diskCan, queryClient, useApiMutation, type SnapshotCreate } from '@oxide/api' +import { + api, + diskCan, + q, + queryClient, + useApiMutation, + type SnapshotCreate, +} from '@oxide/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -26,7 +33,7 @@ import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' const useSnapshotDiskItems = ({ project }: PP.Project) => { - const { data: disks } = useQuery(apiq('diskList', { query: { project, limit: ALL_ISH } })) + const { data: disks } = useQuery(q(api.diskList, { query: { project, limit: ALL_ISH } })) return disks?.items.filter(diskCan.snapshot) } @@ -47,7 +54,7 @@ export default function SnapshotCreate() { const onDismiss = () => navigate(pb.snapshots(projectSelector)) - const createSnapshot = useApiMutation('snapshotCreate', { + const createSnapshot = useApiMutation(api.snapshotCreate, { onSuccess(snapshot) { queryClient.invalidateEndpoint('snapshotList') addToast(<>Snapshot {snapshot.name} created) // prettier-ignore diff --git a/app/forms/ssh-key-create.tsx b/app/forms/ssh-key-create.tsx index 8f3d674b0..9af9f614a 100644 --- a/app/forms/ssh-key-create.tsx +++ b/app/forms/ssh-key-create.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { queryClient, useApiMutation, type SshKeyCreate } from '@oxide/api' +import { api, queryClient, useApiMutation, type SshKeyCreate } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -34,7 +34,7 @@ export function SSHKeyCreate({ onDismiss, message }: Props) { const handleDismiss = onDismiss ? onDismiss : () => navigate(pb.sshKeys()) - const createSshKey = useApiMutation('currentUserSshKeyCreate', { + const createSshKey = useApiMutation(api.currentUserSshKeyCreate, { onSuccess(sshKey) { queryClient.invalidateEndpoint('currentUserSshKeyList') handleDismiss() diff --git a/app/forms/ssh-key-edit.tsx b/app/forms/ssh-key-edit.tsx index 5e180ed1a..930ae4e6d 100644 --- a/app/forms/ssh-key-edit.tsx +++ b/app/forms/ssh-key-edit.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { Key16Icon } from '@oxide/design-system/icons/react' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -24,7 +24,7 @@ import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' const sshKeyView = ({ sshKey }: PP.SshKey) => - apiq('currentUserSshKeyView', { path: { sshKey } }) + q(api.currentUserSshKeyView, { path: { sshKey } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getSshKeySelector(params) diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx index c5b91d37d..89567f758 100644 --- a/app/forms/subnet-create.tsx +++ b/app/forms/subnet-create.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' import type { SetNonNullable } from 'type-fest' -import { queryClient, useApiMutation, type VpcSubnetCreate } from '@oxide/api' +import { api, queryClient, useApiMutation, type VpcSubnetCreate } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { ListboxField } from '~/components/form/fields/ListboxField' @@ -46,7 +46,7 @@ export default function CreateSubnetForm() { const navigate = useNavigate() const onDismiss = () => navigate(pb.vpcSubnets(vpcSelector)) - const createSubnet = useApiMutation('vpcSubnetCreate', { + const createSubnet = useApiMutation(api.vpcSubnetCreate, { onSuccess(subnet) { queryClient.invalidateEndpoint('vpcSubnetList') onDismiss() diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 5dffdd87f..dc2289f85 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -10,7 +10,8 @@ import { useNavigate, type LoaderFunctionArgs } from 'react-router' import type { SetNonNullable } from 'type-fest' import { - apiq, + api, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -35,7 +36,7 @@ import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' const subnetView = ({ project, vpc, subnet }: PP.VpcSubnet) => - apiq('vpcSubnetView', { query: { project, vpc }, path: { subnet } }) + q(api.vpcSubnetView, { query: { project, vpc }, path: { subnet } }) export const handle = titleCrumb('Edit Subnet') @@ -54,7 +55,7 @@ export default function EditSubnetForm() { const { data: subnet } = usePrefetchedQuery(subnetView(subnetSelector)) - const updateSubnet = useApiMutation('vpcSubnetUpdate', { + const updateSubnet = useApiMutation(api.vpcSubnetUpdate, { onSuccess(subnet) { queryClient.invalidateEndpoint('vpcSubnetList') addToast(<>Subnet {subnet.name} updated) // prettier-ignore diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index 176b6bb4c..bc5c6779e 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { apiq, queryClient, useApiMutation, type VpcCreate } from '@oxide/api' +import { api, q, queryClient, useApiMutation, type VpcCreate } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -32,11 +32,14 @@ export default function CreateVpcSideModalForm() { const projectSelector = useProjectSelector() const navigate = useNavigate() - const createVpc = useApiMutation('vpcCreate', { + const createVpc = useApiMutation(api.vpcCreate, { onSuccess(vpc) { queryClient.invalidateEndpoint('vpcList') // avoid the vpc fetch when the vpc page loads since we have the data - const vpcView = apiq('vpcView', { path: { vpc: vpc.name }, query: projectSelector }) + const vpcView = q(api.vpcView, { + path: { vpc: vpc.name }, + query: projectSelector, + }) queryClient.setQueryData(vpcView.queryKey, vpc) 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 01561bd98..5a88be605 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -23,7 +23,7 @@ import type * as PP from '~/util/path-params' export const handle = titleCrumb('Edit VPC') const vpcView = ({ project, vpc }: PP.Vpc) => - apiq('vpcView', { path: { vpc }, query: { project } }) + q(api.vpcView, { path: { vpc }, query: { project } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc } = getVpcSelector(params) @@ -37,7 +37,7 @@ export default function EditVpcSideModalForm() { const { data: vpc } = usePrefetchedQuery(vpcView({ project, vpc: vpcName })) - const editVpc = useApiMutation('vpcUpdate', { + const editVpc = useApiMutation(api.vpcUpdate, { onSuccess(updatedVpc) { queryClient.invalidateEndpoint('vpcList') navigate(pb.vpc({ project, vpc: updatedVpc.name })) diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx index eac5956b7..26f2d90ba 100644 --- a/app/forms/vpc-router-create.tsx +++ b/app/forms/vpc-router-create.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { queryClient, useApiMutation, type VpcRouterCreate } from '@oxide/api' +import { api, queryClient, useApiMutation, type VpcRouterCreate } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -32,7 +32,7 @@ export default function RouterCreate() { const onDismiss = () => navigate(pb.vpcRouters(vpcSelector)) - const createRouter = useApiMutation('vpcRouterCreate', { + const createRouter = useApiMutation(api.vpcRouterCreate, { onSuccess(router) { queryClient.invalidateEndpoint('vpcRouterList') addToast(<>Router {router.name} created) // prettier-ignore diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 9126523e5..f7915b538 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -9,7 +9,8 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, + api, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -27,7 +28,7 @@ import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' const routerView = ({ project, vpc, router }: PP.VpcRouter) => - apiq('vpcRouterView', { path: { router }, query: { project, vpc } }) + q(api.vpcRouterView, { path: { router }, query: { project, vpc } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getVpcRouterSelector(params) @@ -43,7 +44,7 @@ export default function EditRouterSideModalForm() { const { data: routerData } = usePrefetchedQuery(routerView(routerSelector)) const navigate = useNavigate() - const editRouter = useApiMutation('vpcRouterUpdate', { + const editRouter = useApiMutation(api.vpcRouterUpdate, { onSuccess(updatedRouter) { queryClient.invalidateEndpoint('vpcRouterList') addToast(<>Router {updatedRouter.name} updated) // prettier-ignore diff --git a/app/forms/vpc-router-route-common.tsx b/app/forms/vpc-router-route-common.tsx index d9ca3acce..ee2c218f2 100644 --- a/app/forms/vpc-router-route-common.tsx +++ b/app/forms/vpc-router-route-common.tsx @@ -10,7 +10,8 @@ import type { UseFormReturn } from 'react-hook-form' import type { SetNonNullable } from 'type-fest' import { - apiq, + api, + q, usePrefetchedQuery, type RouteDestination, type RouterRouteCreate, @@ -106,14 +107,14 @@ export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => { // usePrefetchedQuery items below are initially fetched in the loaders in vpc-router-route-create and -edit const { data: { items: vpcSubnets }, - } = usePrefetchedQuery(apiq('vpcSubnetList', { query: { project, vpc, limit: ALL_ISH } })) + } = usePrefetchedQuery(q(api.vpcSubnetList, { query: { project, vpc, limit: ALL_ISH } })) const { data: { items: instances }, - } = usePrefetchedQuery(apiq('instanceList', { query: { project, limit: ALL_ISH } })) + } = usePrefetchedQuery(q(api.instanceList, { query: { project, limit: ALL_ISH } })) const { data: { items: internetGateways }, } = usePrefetchedQuery( - apiq('internetGatewayList', { + q(api.internetGatewayList, { query: { project, vpc, limit: ALL_ISH }, }) ) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 85c4ad54a..e0f89bf74 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -8,7 +8,7 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, useApiMutation } from '@oxide/api' +import { api, q, queryClient, useApiMutation } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' @@ -32,11 +32,11 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc } = getVpcRouterSelector(params) await Promise.all([ queryClient.prefetchQuery( - apiq('vpcSubnetList', { query: { project, vpc, limit: ALL_ISH } }) + q(api.vpcSubnetList, { query: { project, vpc, limit: ALL_ISH } }) ), - queryClient.prefetchQuery(apiq('instanceList', { query: { project, limit: ALL_ISH } })), + queryClient.prefetchQuery(q(api.instanceList, { query: { project, limit: ALL_ISH } })), queryClient.prefetchQuery( - apiq('internetGatewayList', { query: { project, vpc, limit: ALL_ISH } }) + q(api.internetGatewayList, { query: { project, vpc, limit: ALL_ISH } }) ), ]) return null @@ -48,7 +48,7 @@ export default function CreateRouterRouteSideModalForm() { const form = useForm({ defaultValues }) - const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { + const createRouterRoute = useApiMutation(api.vpcRouterRouteCreate, { onSuccess(route) { queryClient.invalidateEndpoint('vpcRouterRouteList') addToast(<>Route {route.name} created) // prettier-ignore diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index a6c90b965..1b62bf767 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import * as R from 'remeda' -import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' @@ -30,14 +30,17 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc, router, route } = getVpcRouterRouteSelector(params) await Promise.all([ queryClient.prefetchQuery( - apiq('vpcRouterRouteView', { path: { route }, query: { project, vpc, router } }) + q(api.vpcRouterRouteView, { + path: { route }, + query: { project, vpc, router }, + }) ), queryClient.prefetchQuery( - apiq('vpcSubnetList', { query: { project, vpc, limit: ALL_ISH } }) + q(api.vpcSubnetList, { query: { project, vpc, limit: ALL_ISH } }) ), - queryClient.prefetchQuery(apiq('instanceList', { query: { project, limit: ALL_ISH } })), + queryClient.prefetchQuery(q(api.instanceList, { query: { project, limit: ALL_ISH } })), queryClient.prefetchQuery( - apiq('internetGatewayList', { query: { project, vpc, limit: ALL_ISH } }) + q(api.internetGatewayList, { query: { project, vpc, limit: ALL_ISH } }) ), ]) return null @@ -47,7 +50,10 @@ export default function EditRouterRouteSideModalForm() { const { route: routeName, ...routerSelector } = useVpcRouterRouteSelector() const navigate = useNavigate() const { data: route } = usePrefetchedQuery( - apiq('vpcRouterRouteView', { path: { route: routeName }, query: routerSelector }) + q(api.vpcRouterRouteView, { + path: { route: routeName }, + query: routerSelector, + }) ) const defaultValues: RouteFormValues = R.pick(route, [ @@ -59,7 +65,7 @@ export default function EditRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const disabled = route?.kind === 'vpc_subnet' - const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', { + const updateRouterRoute = useApiMutation(api.vpcRouterRouteUpdate, { onSuccess(updatedRoute) { queryClient.invalidateEndpoint('vpcRouterRouteList') queryClient.invalidateEndpoint('vpcRouterRouteView') diff --git a/app/hooks/use-current-user.ts b/app/hooks/use-current-user.ts index 163480c70..2f07b82c0 100644 --- a/app/hooks/use-current-user.ts +++ b/app/hooks/use-current-user.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { apiq, usePrefetchedQuery } from '~/api/client' +import { api, q, usePrefetchedQuery } from '~/api/client' /** * Access all the data fetched by the loader. Because of the `shouldRevalidate` @@ -15,7 +15,7 @@ import { apiq, usePrefetchedQuery } from '~/api/client' * loaders. */ export function useCurrentUser() { - const { data: me } = usePrefetchedQuery(apiq('currentUserView', {})) - const { data: myGroups } = usePrefetchedQuery(apiq('currentUserGroups', {})) + const { data: me } = usePrefetchedQuery(q(api.currentUserView, {})) + const { data: myGroups } = usePrefetchedQuery(q(api.currentUserGroups, {})) return { me, myGroups } } diff --git a/app/layouts/AuthenticatedLayout.tsx b/app/layouts/AuthenticatedLayout.tsx index a618c7049..b61013826 100644 --- a/app/layouts/AuthenticatedLayout.tsx +++ b/app/layouts/AuthenticatedLayout.tsx @@ -7,7 +7,7 @@ */ import { Outlet } from 'react-router' -import { apiq, queryClient } from '@oxide/api' +import { api, q, queryClient } from '@oxide/api' import { RouterDataErrorBoundary } from '~/components/ErrorBoundary' import { QuickActions } from '~/hooks/use-quick-actions' @@ -26,8 +26,8 @@ export function ErrorBoundary() { export async function clientLoader() { const staleTime = 60000 await Promise.all([ - queryClient.prefetchQuery({ ...apiq('currentUserView', {}), staleTime }), - queryClient.prefetchQuery({ ...apiq('currentUserGroups', {}), staleTime }), + queryClient.prefetchQuery(q(api.currentUserView, {}, { staleTime })), + queryClient.prefetchQuery(q(api.currentUserGroups, {}, { staleTime })), ]) return null } diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 32c0ed74f..74e734a84 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -8,7 +8,7 @@ import { useMemo, type ReactElement } from 'react' import { useLocation, useNavigate, type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { Access16Icon, Affinity16Icon, @@ -44,7 +44,7 @@ type ProjectLayoutProps = { overrideContentPane?: ReactElement } -const projectView = ({ project }: PP.Project) => apiq('projectView', { path: { project } }) +const projectView = ({ project }: PP.Project) => q(api.projectView, { path: { project } }) export async function projectLayoutLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 5999eef09..1e3a10c4d 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -8,7 +8,7 @@ import { useMemo } from 'react' import { useLocation, useNavigate } from 'react-router' -import { apiq, queryClient } from '@oxide/api' +import { api, q, queryClient } from '@oxide/api' import { Cloud16Icon, IpGlobal16Icon, @@ -33,7 +33,7 @@ import { ContentPane, PageContainer } from './helpers' * doesn't return the result. */ export async function clientLoader() { - const me = await queryClient.fetchQuery(apiq('currentUserView', {})) + const me = await queryClient.fetchQuery(q(api.currentUserView, {})) if (!me.fleetViewer) throw trigger404 return null } diff --git a/app/pages/DeviceAuthVerifyPage.tsx b/app/pages/DeviceAuthVerifyPage.tsx index b8baf2645..9ce3d377a 100644 --- a/app/pages/DeviceAuthVerifyPage.tsx +++ b/app/pages/DeviceAuthVerifyPage.tsx @@ -8,7 +8,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router' -import { useApiMutation } from '@oxide/api' +import { api, useApiMutation } from '@oxide/api' import { Warning12Icon } from '@oxide/design-system/icons/react' import { AuthCodeInput } from '~/ui/lib/AuthCodeInput' @@ -23,7 +23,7 @@ const DASH_AFTER_IDXS = [3] */ export default function DeviceAuthVerifyPage() { const navigate = useNavigate() - const confirmPost = useApiMutation('deviceAuthConfirm', { + const confirmPost = useApiMutation(api.deviceAuthConfirm, { onSuccess: () => { navigate(pb.deviceSuccess()) }, diff --git a/app/pages/InstanceLookup.tsx b/app/pages/InstanceLookup.tsx index 715657410..a472ec4a9 100644 --- a/app/pages/InstanceLookup.tsx +++ b/app/pages/InstanceLookup.tsx @@ -7,7 +7,7 @@ */ import { redirect, type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient } from '@oxide/api' +import { api, q, queryClient } from '@oxide/api' import { trigger404 } from '~/components/ErrorBoundary' import { pb } from '~/util/path-builder' @@ -15,10 +15,10 @@ import { pb } from '~/util/path-builder' export async function clientLoader({ params }: LoaderFunctionArgs) { try { const instance = await queryClient.fetchQuery( - apiq('instanceView', { path: { instance: params.instance! } }) + q(api.instanceView, { path: { instance: params.instance! } }) ) const project = await queryClient.fetchQuery( - apiq('projectView', { path: { project: instance.projectId } }) + q(api.projectView, { path: { project: instance.projectId } }) ) return redirect(pb.instance({ project: project.name, instance: instance.name })) } catch (_e) { diff --git a/app/pages/LoginPage.tsx b/app/pages/LoginPage.tsx index e4d3c5214..09a5f75f4 100644 --- a/app/pages/LoginPage.tsx +++ b/app/pages/LoginPage.tsx @@ -9,7 +9,7 @@ import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { useNavigate, useSearchParams } from 'react-router' -import { useApiMutation, type UsernamePasswordCredentials } from '@oxide/api' +import { api, useApiMutation, type UsernamePasswordCredentials } from '@oxide/api' import { TextFieldInner } from '~/components/form/fields/TextField' import { useSiloSelector } from '~/hooks/use-params' @@ -31,7 +31,7 @@ export default function LoginPage() { const form = useForm({ defaultValues }) - const loginPost = useApiMutation('loginLocal') + const loginPost = useApiMutation(api.loginLocal) useEffect(() => { if (loginPost.isSuccess) { diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx index 539cf4a0d..2e351e0c4 100644 --- a/app/pages/ProjectsPage.tsx +++ b/app/pages/ProjectsPage.tsx @@ -9,7 +9,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Outlet, useNavigate } from 'react-router' -import { apiq, getListQFn, queryClient, useApiMutation, type Project } from '@oxide/api' +import { api, getListQFn, q, queryClient, useApiMutation, type Project } from '@oxide/api' import { Folder16Icon, Folder24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -37,7 +37,7 @@ const EmptyState = () => ( /> ) -const projectList = getListQFn('projectList', {}) +const projectList = getListQFn(api.projectList, {}) export async function clientLoader() { // fetchQuery instead of prefetchQuery means errors blow up here instead of @@ -62,7 +62,7 @@ const staticCols = [ export default function ProjectsPage() { const navigate = useNavigate() - const { mutateAsync: deleteProject } = useApiMutation('projectDelete', { + const { mutateAsync: deleteProject } = useApiMutation(api.projectDelete, { onSuccess() { queryClient.invalidateEndpoint('projectList') }, @@ -75,7 +75,9 @@ export default function ProjectsPage() { onActivate: () => { // the edit view has its own loader, but we can make the modal open // instantaneously by preloading the fetch result - const { queryKey } = apiq('projectView', { path: { project: project.name } }) + const { queryKey } = q(api.projectView, { + path: { project: project.name }, + }) queryClient.setQueryData(queryKey, project) navigate(pb.projectEdit({ project: project.name })) }, diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index a68b134f3..eb65e9535 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -9,10 +9,11 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/re import { useMemo, useState } from 'react' import { - apiq, + api, byGroupThenName, deleteRole, getEffectiveRole, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -52,9 +53,9 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( ) -const policyView = apiq('policyView', {}) -const userList = apiq('userList', {}) -const groupList = apiq('groupList', {}) +const policyView = q(api.policyView, {}) +const userList = q(api.userList, {}) +const groupList = q(api.groupList, {}) export async function clientLoader() { await Promise.all([ @@ -108,7 +109,7 @@ export default function SiloAccessPage() { .sort(byGroupThenName) }, [siloRows]) - const { mutateAsync: updatePolicy } = useApiMutation('policyUpdate', { + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => queryClient.invalidateEndpoint('policyView'), // TODO: handle 403 }) diff --git a/app/pages/SiloImageEdit.tsx b/app/pages/SiloImageEdit.tsx index c850dff52..95e3db9a4 100644 --- a/app/pages/SiloImageEdit.tsx +++ b/app/pages/SiloImageEdit.tsx @@ -7,7 +7,7 @@ */ import { type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { EditImageSideModalForm } from '~/forms/image-edit' import { titleCrumb } from '~/hooks/use-crumbs' @@ -15,7 +15,7 @@ import { getSiloImageSelector, useSiloImageSelector } from '~/hooks/use-params' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' -const imageView = ({ image }: PP.SiloImage) => apiq('imageView', { path: { image } }) +const imageView = ({ image }: PP.SiloImage) => q(api.imageView, { path: { image } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getSiloImageSelector(params) diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx index e46562486..d3d14a1e0 100644 --- a/app/pages/SiloImagesPage.tsx +++ b/app/pages/SiloImagesPage.tsx @@ -11,7 +11,7 @@ import { useCallback, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { Outlet } from 'react-router' -import { apiq, getListQFn, queryClient, useApiMutation, type Image } from '@oxide/api' +import { api, getListQFn, q, queryClient, useApiMutation, type Image } from '@oxide/api' import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -43,7 +43,7 @@ const EmptyState = () => ( /> ) -const imageList = getListQFn('imageList', {}) +const imageList = getListQFn(api.imageList, {}) export async function clientLoader() { await queryClient.prefetchQuery(imageList.optionsFn()) @@ -66,7 +66,7 @@ export default function SiloImagesPage() { const [showModal, setShowModal] = useState(false) const [demoteImage, setDemoteImage] = useState(null) - const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { + const { mutateAsync: deleteImage } = useApiMutation(api.imageDelete, { onSuccess(_data, variables) { addToast(<>Image {variables.path.image} deleted) // prettier-ignore queryClient.invalidateEndpoint('imageList') @@ -124,7 +124,7 @@ const defaultValues: Values = { project: null, image: null } const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { const form = useForm({ defaultValues }) - const promoteImage = useApiMutation('imagePromote', { + const promoteImage = useApiMutation(api.imagePromote, { onSuccess(data) { addToast(<>Image {data.name} promoted) // prettier-ignore queryClient.invalidateEndpoint('imageList') @@ -135,14 +135,14 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { onSettled: onDismiss, }) - const projects = useQuery(apiq('projectList', {})) + const projects = useQuery(q(api.projectList, {})) const projectItems = useMemo(() => toComboboxItems(projects.data?.items), [projects.data]) const selectedProject = form.watch('project') // can only fetch images if a project is selected const images = useQuery( - apiq( - 'imageList', + q( + api.imageList, { query: { project: selectedProject! } }, { enabled: !!selectedProject } ) @@ -210,7 +210,7 @@ const DemoteImageModal = ({ const selectedProject: string | undefined = form.watch('project') - const demoteImage = useApiMutation('imageDemote', { + const demoteImage = useApiMutation(api.imageDemote, { onSuccess(data) { addToast({ content: <>Image {data.name} demoted, // prettier-ignore @@ -230,7 +230,7 @@ const DemoteImageModal = ({ onSettled: onDismiss, }) - const projects = useQuery(apiq('projectList', {})) + const projects = useQuery(q(api.projectList, {})) const projectItems = useMemo(() => toComboboxItems(projects.data?.items), [projects.data]) return ( diff --git a/app/pages/SiloUtilizationPage.tsx b/app/pages/SiloUtilizationPage.tsx index 3b2c08af8..d0c768f6a 100644 --- a/app/pages/SiloUtilizationPage.tsx +++ b/app/pages/SiloUtilizationPage.tsx @@ -9,7 +9,7 @@ import { getLocalTimeZone, now } from '@internationalized/date' import { useIsFetching } from '@tanstack/react-query' import { useMemo, useState } from 'react' -import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { Metrics16Icon, Metrics24Icon } from '@oxide/design-system/icons/react' import { CapacityBars } from '~/components/CapacityBars' @@ -26,8 +26,8 @@ import { bytesToGiB, bytesToTiB } from '~/util/units' const toListboxItem = (x: { name: string; id: string }) => ({ label: x.name, value: x.id }) -const projectList = apiq('projectList', {}) -const utilizationView = apiq('utilizationView', {}) +const projectList = q(api.projectList, {}) +const utilizationView = q(api.utilizationView, {}) export const handle = { crumb: 'Utilization' } diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 79a2f5e16..17cf4d453 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -12,9 +12,10 @@ import type { LoaderFunctionArgs } from 'react-router' import * as R from 'remeda' import { - apiq, + api, byGroupThenName, deleteRole, + q, queryClient, roleOrder, useApiMutation, @@ -48,11 +49,11 @@ import { groupBy } from '~/util/array' import { docLinks } from '~/util/links' import type * as PP from '~/util/path-params' -const policyView = apiq('policyView', {}) +const policyView = q(api.policyView, {}) const projectPolicyView = ({ project }: PP.Project) => - apiq('projectPolicyView', { path: { project } }) -const userList = apiq('userList', {}) -const groupList = apiq('groupList', {}) + q(api.projectPolicyView, { path: { project } }) +const userList = q(api.userList, {}) +const groupList = q(api.groupList, {}) const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -125,7 +126,7 @@ export default function ProjectAccessPage() { .sort(byGroupThenName) }, [siloRows, projectRows]) - const { mutateAsync: updatePolicy } = useApiMutation('projectPolicyUpdate', { + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('projectPolicyView') addToast({ content: 'Access removed' }) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 0f221710c..602988200 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -11,6 +11,7 @@ import { useCallback } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' import { + api, queryClient, useApiMutation, usePrefetchedQuery, @@ -81,7 +82,7 @@ export default function AffinityPage() { data: { items: antiAffinityGroups }, } = usePrefetchedQuery(antiAffinityGroupList({ project })) - const { mutateAsync: deleteGroup } = useApiMutation('antiAffinityGroupDelete', { + const { mutateAsync: deleteGroup } = useApiMutation(api.antiAffinityGroupDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('antiAffinityGroupList') addToast( diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 19f48af39..b2d85cb76 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -14,6 +14,7 @@ import { Affinity24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { + api, queryClient, useApiMutation, usePrefetchedQuery, @@ -96,7 +97,7 @@ export default function AntiAffinityPage() { ) const { mutateAsync: removeMember } = useApiMutation( - 'antiAffinityGroupMemberInstanceDelete', + api.antiAffinityGroupMemberInstanceDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('antiAffinityGroupMemberList') @@ -108,7 +109,7 @@ export default function AntiAffinityPage() { const navigate = useNavigate() - const { mutateAsync: deleteGroup } = useApiMutation('antiAffinityGroupDelete', { + const { mutateAsync: deleteGroup } = useApiMutation(api.antiAffinityGroupDelete, { onSuccess() { navigate(pb.affinity({ project })) queryClient.invalidateEndpoint('antiAffinityGroupList') diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 7e5be694d..e4530a6a4 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -10,10 +10,11 @@ import { useCallback } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' import { - apiq, + api, diskCan, genName, getListQFn, + q, queryClient, useApiMutation, type Disk, @@ -54,8 +55,8 @@ const EmptyState = () => ( ) const instanceList = ({ project }: PP.Project) => - getListQFn('instanceList', { query: { project, limit: 200 } }) -const diskList = (query: PP.Project) => getListQFn('diskList', { query }) + getListQFn(api.instanceList, { query: { project, limit: 200 } }) +const diskList = (query: PP.Project) => getListQFn(api.diskList, { query }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) @@ -67,7 +68,9 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { // fetching individually if we don't fetch them all here queryClient.fetchQuery(instanceList({ project }).optionsFn()).then((instances) => { for (const instance of instances.items) { - const { queryKey } = apiq('instanceView', { path: { instance: instance.id } }) + const { queryKey } = q(api.instanceView, { + path: { instance: instance.id }, + }) queryClient.setQueryData(queryKey, instance) } }), @@ -99,14 +102,14 @@ const staticCols = [ export default function DisksPage() { const { project } = useProjectSelector() - const { mutateAsync: deleteDisk } = useApiMutation('diskDelete', { + const { mutateAsync: deleteDisk } = useApiMutation(api.diskDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('diskList') addToast(<>Disk {variables.path.disk} deleted) // prettier-ignore }, }) - const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { + const { mutate: createSnapshot } = useApiMutation(api.snapshotCreate, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('snapshotList') addToast(<>Snapshot {variables.body.name} created) // prettier-ignore diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index b4a6caa74..cf7cd75d1 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -11,8 +11,9 @@ import { useForm } from 'react-hook-form' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, + api, getListQFn, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -55,9 +56,9 @@ const EmptyState = () => ( /> ) -const fipList = (project: string) => getListQFn('floatingIpList', { query: { project } }) +const fipList = (project: string) => getListQFn(api.floatingIpList, { query: { project } }) const instanceList = (project: string) => - getListQFn('instanceList', { query: { project, limit: ALL_ISH } }) + getListQFn(api.instanceList, { query: { project, limit: ALL_ISH } }) export const handle = makeCrumb('Floating IPs', (p) => pb.floatingIps(getProjectSelector(p)) @@ -72,10 +73,12 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { // IpPoolCell can be mostly instant yet gracefully fall back to // fetching individually if we don't fetch them all here queryClient - .fetchQuery(apiq('projectIpPoolList', { query: { limit: ALL_ISH } })) + .fetchQuery(q(api.projectIpPoolList, { query: { limit: ALL_ISH } })) .then((pools) => { for (const pool of pools.items) { - const { queryKey } = apiq('projectIpPoolView', { path: { pool: pool.id } }) + const { queryKey } = q(api.projectIpPoolView, { + path: { pool: pool.id }, + }) queryClient.setQueryData(queryKey, pool) } }), @@ -107,7 +110,7 @@ export default function FloatingIpsPage() { const { data: instances } = usePrefetchedQuery(instanceList(project).optionsFn()) const navigate = useNavigate() - const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { + const { mutateAsync: floatingIpDetach } = useApiMutation(api.floatingIpDetach, { onSuccess(floatingIp) { queryClient.invalidateEndpoint('floatingIpList') addToast(<>Floating IP {floatingIp.name} detached) // prettier-ignore @@ -116,7 +119,7 @@ export default function FloatingIpsPage() { addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) - const { mutateAsync: deleteFloatingIp } = useApiMutation('floatingIpDelete', { + const { mutateAsync: deleteFloatingIp } = useApiMutation(api.floatingIpDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('floatingIpList') queryClient.invalidateEndpoint('ipPoolUtilizationView') @@ -171,7 +174,7 @@ export default function FloatingIpsPage() { { label: 'Edit', onActivate: () => { - const { queryKey } = apiq('floatingIpView', { + const { queryKey } = q(api.floatingIpView, { path: { floatingIp: floatingIp.name }, query: { project }, }) @@ -248,7 +251,7 @@ const AttachFloatingIpModal = ({ project: string onDismiss: () => void }) => { - const floatingIpAttach = useApiMutation('floatingIpAttach', { + const floatingIpAttach = useApiMutation(api.floatingIpAttach, { onSuccess(floatingIp) { queryClient.invalidateEndpoint('floatingIpList') addToast(<>Floating IP {floatingIp.name} attached) // prettier-ignore diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index 6444caa70..813164812 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -9,7 +9,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' -import { getListQFn, queryClient, useApiMutation, type Image } from '@oxide/api' +import { api, getListQFn, queryClient, useApiMutation, type Image } from '@oxide/api' import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -44,7 +44,7 @@ const EmptyState = () => ( const colHelper = createColumnHelper() -const imageList = (query: PP.Project) => getListQFn('imageList', { query }) +const imageList = (query: PP.Project) => getListQFn(api.imageList, { query }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) @@ -59,7 +59,7 @@ export default function ImagesPage() { const [promoteImageName, setPromoteImageName] = useState(null) - const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { + const { mutateAsync: deleteImage } = useApiMutation(api.imageDelete, { onSuccess(_data, variables) { addToast(<>Image {variables.path.image} deleted) // prettier-ignore queryClient.invalidateEndpoint('imageList') @@ -136,7 +136,7 @@ type PromoteModalProps = { onDismiss: () => void; imageName: string } const PromoteImageModal = ({ onDismiss, imageName }: PromoteModalProps) => { const { project } = useProjectSelector() - const promoteImage = useApiMutation('imagePromote', { + const promoteImage = useApiMutation(api.imagePromote, { onSuccess(data) { addToast({ content: ( diff --git a/app/pages/project/images/ProjectImageEdit.tsx b/app/pages/project/images/ProjectImageEdit.tsx index e8e8fec91..f089c5e05 100644 --- a/app/pages/project/images/ProjectImageEdit.tsx +++ b/app/pages/project/images/ProjectImageEdit.tsx @@ -7,7 +7,7 @@ */ import { type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { EditImageSideModalForm } from '~/forms/image-edit' import { titleCrumb } from '~/hooks/use-crumbs' @@ -16,7 +16,7 @@ import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' const imageView = ({ image, project }: PP.Image) => - apiq('imageView', { path: { image }, query: { project } }) + q(api.imageView, { path: { image }, query: { project } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getProjectImageSelector(params) diff --git a/app/pages/project/instances/AntiAffinityCard.tsx b/app/pages/project/instances/AntiAffinityCard.tsx index a345151ca..e27c52902 100644 --- a/app/pages/project/instances/AntiAffinityCard.tsx +++ b/app/pages/project/instances/AntiAffinityCard.tsx @@ -12,8 +12,9 @@ import { useForm } from 'react-hook-form' import * as R from 'remeda' import { - apiq, + api, instanceCan, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -45,7 +46,7 @@ import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' export const instanceAntiAffinityGroups = ({ project, instance }: PP.Instance) => - apiq('instanceAntiAffinityGroupList', { + q(api.instanceAntiAffinityGroupList, { path: { instance }, query: { project, limit: ALL_ISH }, }) @@ -68,7 +69,7 @@ export function AntiAffinityCard() { ) const { data: allGroups } = usePrefetchedQuery(antiAffinityGroupList(instanceSelector)) const { data: instanceData } = usePrefetchedQuery( - apiq('instanceView', { path: { instance }, query: { project } }) + q(api.instanceView, { path: { instance }, query: { project } }) ) const nonMemberGroups = useMemo( @@ -77,7 +78,7 @@ export function AntiAffinityCard() { ) const { mutateAsync: removeMember } = useApiMutation( - 'antiAffinityGroupMemberInstanceDelete', + api.antiAffinityGroupMemberInstanceDelete, { onSuccess(_data, variables) { addToast( @@ -203,26 +204,29 @@ export function AddToGroupModal({ onDismiss, nonMemberGroups }: ModalProps) { const form = useForm({ defaultValues: { group: '' } }) const formId = useId() - const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', { - onSuccess(_data, variables) { - onDismiss() - queryClient.invalidateEndpoint('antiAffinityGroupMemberList') - queryClient.invalidateEndpoint('instanceAntiAffinityGroupList') - addToast( - <> - Instance {instance} added to anti-affinity group{' '} - {variables.path.antiAffinityGroup} - - ) - }, - onError(error) { - addToast({ - title: 'Failed to add instance to group', - content: error.message, - variant: 'error', - }) - }, - }) + const { mutateAsync: addMember } = useApiMutation( + api.antiAffinityGroupMemberInstanceAdd, + { + onSuccess(_data, variables) { + onDismiss() + queryClient.invalidateEndpoint('antiAffinityGroupMemberList') + queryClient.invalidateEndpoint('instanceAntiAffinityGroupList') + addToast( + <> + Instance {instance} added to anti-affinity group{' '} + {variables.path.antiAffinityGroup} + + ) + }, + onError(error) { + addToast({ + title: 'Failed to add instance to group', + content: error.message, + variant: 'error', + }) + }, + } + ) const handleSubmit = form.handleSubmit(({ group }) => { addMember({ diff --git a/app/pages/project/instances/AutoRestartCard.tsx b/app/pages/project/instances/AutoRestartCard.tsx index 55f8e7060..0e571aa8e 100644 --- a/app/pages/project/instances/AutoRestartCard.tsx +++ b/app/pages/project/instances/AutoRestartCard.tsx @@ -12,8 +12,9 @@ import { useForm } from 'react-hook-form' import { match } from 'ts-pattern' import { - apiq, + api, instanceAutoRestartingSoon, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -44,13 +45,13 @@ export function AutoRestartCard() { const instanceSelector = useInstanceSelector() const { data: instance } = usePrefetchedQuery( - apiq('instanceView', { + q(api.instanceView, { path: { instance: instanceSelector.instance }, query: { project: instanceSelector.project }, }) ) - const instanceUpdate = useApiMutation('instanceUpdate', { + const instanceUpdate = useApiMutation(api.instanceUpdate, { onSuccess() { queryClient.invalidateEndpoint('instanceView') addToast({ content: 'Instance auto-restart policy updated' }) diff --git a/app/pages/project/instances/ConnectTab.tsx b/app/pages/project/instances/ConnectTab.tsx index bc1e6bf6a..126512740 100644 --- a/app/pages/project/instances/ConnectTab.tsx +++ b/app/pages/project/instances/ConnectTab.tsx @@ -8,7 +8,7 @@ import { Link, type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, usePrefetchedQuery } from '~/api' +import { api, q, queryClient, usePrefetchedQuery } from '~/api' import { EquivalentCliCommand } from '~/components/CopyCode' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { buttonStyle } from '~/ui/lib/Button' @@ -20,7 +20,7 @@ import { pb } from '~/util/path-builder' export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await queryClient.prefetchQuery( - apiq('instanceExternalIpList', { + q(api.instanceExternalIpList, { path: { instance }, query: { project }, }) @@ -33,7 +33,7 @@ export const handle = { crumb: 'Connect' } export default function ConnectTab() { const { project, instance } = useInstanceSelector() const { data: externalIps } = usePrefetchedQuery( - apiq('instanceExternalIpList', { + q(api.instanceExternalIpList, { path: { instance }, query: { project }, }) diff --git a/app/pages/project/instances/CpuMetricsTab.tsx b/app/pages/project/instances/CpuMetricsTab.tsx index 079061697..62ca844da 100644 --- a/app/pages/project/instances/CpuMetricsTab.tsx +++ b/app/pages/project/instances/CpuMetricsTab.tsx @@ -8,7 +8,7 @@ import { useState } from 'react' -import { apiq, usePrefetchedQuery } from '~/api' +import { api, q, usePrefetchedQuery } from '~/api' import { MetricCollection, MetricHeader, @@ -33,7 +33,7 @@ const descriptions: Record = { export default function CpuMetricsTab() { const { project, instance } = useInstanceSelector() const { data: instanceData } = usePrefetchedQuery( - apiq('instanceView', { path: { instance }, query: { project } }) + q(api.instanceView, { path: { instance }, query: { project } }) ) const { startTime, endTime, dateTimeRangePicker } = useMetricsContext() diff --git a/app/pages/project/instances/DiskMetricsTab.tsx b/app/pages/project/instances/DiskMetricsTab.tsx index 621ec4669..ab0edfe71 100644 --- a/app/pages/project/instances/DiskMetricsTab.tsx +++ b/app/pages/project/instances/DiskMetricsTab.tsx @@ -9,7 +9,14 @@ import { useMemo, useState } from 'react' import { type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, usePrefetchedQuery, type Disk, type Instance } from '@oxide/api' +import { + api, + q, + queryClient, + usePrefetchedQuery, + type Disk, + type Instance, +} from '@oxide/api' import { Storage24Icon } from '@oxide/design-system/icons/react' import { @@ -29,7 +36,7 @@ import { useMetricsContext } from './common' export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await queryClient.prefetchQuery( - apiq('instanceDiskList', { path: { instance }, query: { project } }) + q(api.instanceDiskList, { path: { instance }, query: { project } }) ) return null } @@ -40,11 +47,11 @@ const groupByAttachedInstanceId = { cols: ['attached_instance_id'], op: 'sum' } export default function DiskMetricsTab() { const { project, instance } = useInstanceSelector() const { data: disks } = usePrefetchedQuery( - apiq('instanceDiskList', { path: { instance }, query: { project } }) + q(api.instanceDiskList, { path: { instance }, query: { project } }) ) const { data: instanceData } = usePrefetchedQuery( - apiq('instanceView', { path: { instance }, query: { project } }) + q(api.instanceView, { path: { instance }, query: { project } }) ) if (disks.items.length === 0) { return ( diff --git a/app/pages/project/instances/InstancePage.tsx b/app/pages/project/instances/InstancePage.tsx index 9cc6dde91..e0728c4b6 100644 --- a/app/pages/project/instances/InstancePage.tsx +++ b/app/pages/project/instances/InstancePage.tsx @@ -12,7 +12,8 @@ import { useForm } from 'react-hook-form' import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, + api, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -60,15 +61,15 @@ import { GiB } from '~/util/units' import { useMakeInstanceActions } from './actions' const instanceView = ({ project, instance }: PP.Instance) => - apiq('instanceView', { path: { instance }, query: { project } }) + q(api.instanceView, { path: { instance }, query: { project } }) const instanceExternalIpList = ({ project, instance }: PP.Instance) => - apiq('instanceExternalIpList', { path: { instance }, query: { project } }) + q(api.instanceExternalIpList, { path: { instance }, query: { project } }) const instanceNetworkInterfaceList = (query: PP.Instance) => - apiq('instanceNetworkInterfaceList', { query }) + q(api.instanceNetworkInterfaceList, { query }) -const vpcView = (vpc: string) => apiq('vpcView', { path: { vpc } }) +const vpcView = (vpc: string) => q(api.vpcView, { path: { vpc } }) function getPrimaryVpcId(nics: InstanceNetworkInterface[]) { const nic = nics.find((nic) => nic.primary) @@ -278,7 +279,7 @@ export function ResizeInstanceModal({ onListView?: boolean }) { const { project } = useProjectSelector() - const instanceUpdate = useApiMutation('instanceUpdate', { + const instanceUpdate = useApiMutation(api.instanceUpdate, { onSuccess(_updatedInstance) { queryClient.invalidateEndpoint('instanceList') queryClient.invalidateEndpoint('instanceView') diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 8b68b4aba..9f313cace 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -12,6 +12,7 @@ import { useMemo, useRef, useState } from 'react' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import { + api, getListQFn, queryClient, type ApiError, @@ -60,7 +61,7 @@ const instanceList = ( // kinda gnarly, but we need refetchInterval in the component but not in the loader. // pick refetchInterval to avoid annoying type conflicts on the full object options?: Pick, 'refetchInterval'> -) => getListQFn('instanceList', { query: { project } }, options) +) => getListQFn(api.instanceList, { query: { project } }, options) export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) diff --git a/app/pages/project/instances/NetworkMetricsTab.tsx b/app/pages/project/instances/NetworkMetricsTab.tsx index 57bfa01b5..a6f60cb1c 100644 --- a/app/pages/project/instances/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/NetworkMetricsTab.tsx @@ -10,7 +10,8 @@ import { useMemo, useState } from 'react' import { type LoaderFunctionArgs } from 'react-router' import { - apiq, + api, + q, queryClient, usePrefetchedQuery, type InstanceNetworkInterface, @@ -34,7 +35,7 @@ import { useMetricsContext } from './common' export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await queryClient.prefetchQuery( - apiq('instanceNetworkInterfaceList', { + q(api.instanceNetworkInterfaceList, { query: { project, instance, limit: ALL_ISH }, }) ) @@ -46,7 +47,7 @@ const groupByInstanceId = { cols: ['instance_id'], op: 'sum' } as const export default function NetworkMetricsTab() { const { project, instance } = useInstanceSelector() const { data: nics } = usePrefetchedQuery( - apiq('instanceNetworkInterfaceList', { + q(api.instanceNetworkInterfaceList, { query: { project, instance, limit: ALL_ISH }, }) ) @@ -69,7 +70,7 @@ export default function NetworkMetricsTab() { function NetworkMetrics({ nics }: { nics: InstanceNetworkInterface[] }) { const { project, instance } = useInstanceSelector() const { data: instanceData } = usePrefetchedQuery( - apiq('instanceView', { path: { instance }, query: { project } }) + q(api.instanceView, { path: { instance }, query: { project } }) ) const { startTime, endTime, dateTimeRangePicker } = useMetricsContext() diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index c3cdda6c9..09db5e6f9 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -12,9 +12,10 @@ import { type LoaderFunctionArgs } from 'react-router' import { match } from 'ts-pattern' import { - apiq, - apiqErrorsAllowed, + api, instanceCan, + q, + qErrorsAllowed, queryClient, useApiMutation, usePrefetchedQuery, @@ -62,7 +63,7 @@ import { fancifyStates } from './common' const VpcNameFromId = ({ value }: { value: string }) => { const { project } = useProjectSelector() const { data: vpc, isError } = useQuery( - apiq('vpcView', { path: { vpc: value } }, { throwOnError: false }) + q(api.vpcView, { path: { vpc: value } }, { throwOnError: false }) ) // If we can't find it, it must have been deleted. This is probably not @@ -75,7 +76,7 @@ const VpcNameFromId = ({ value }: { value: string }) => { const SubnetNameFromId = ({ value }: { value: string }) => { const { data: subnet, isError } = useQuery( - apiq('vpcSubnetView', { path: { subnet: value } }, { throwOnError: false }) + q(api.vpcSubnetView, { path: { subnet: value } }, { throwOnError: false }) ) // same deal as VPC: probably not possible but let's be safe @@ -100,30 +101,28 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await Promise.all([ queryClient.fetchQuery( - apiq('instanceNetworkInterfaceList', { + q(api.instanceNetworkInterfaceList, { // we want this to cover all NICs; TODO: determine actual limit? query: { project, instance, limit: ALL_ISH }, }) ), - queryClient.fetchQuery(apiq('floatingIpList', { query: { project, limit: ALL_ISH } })), + queryClient.fetchQuery(q(api.floatingIpList, { query: { project, limit: ALL_ISH } })), // dupe of page-level fetch but that's fine, RQ dedupes queryClient.fetchQuery( - apiq('instanceExternalIpList', { path: { instance }, query: { project } }) + q(api.instanceExternalIpList, { path: { instance }, query: { project } }) ), // This is covered by the InstancePage loader but there's no downside to // being redundant. If it were removed there, we'd still want it here. - queryClient.fetchQuery( - apiq('instanceView', { path: { instance }, query: { project } }) - ), + queryClient.fetchQuery(q(api.instanceView, { path: { instance }, query: { project } })), // Fetch IP Pools and preload into RQ cache so fetches by ID in // IpPoolCell and AttachFloatingIpModal can be mostly instant queryClient - .fetchQuery(apiq('projectIpPoolList', { query: { limit: ALL_ISH } })) + .fetchQuery(q(api.projectIpPoolList, { query: { limit: ALL_ISH } })) .then((pools) => { for (const pool of pools.items) { // both IpPoolCell and the fetch in the model use errors-allowed // versions to avoid blowing up in the unlikely event of an error - const { queryKey } = apiqErrorsAllowed('projectIpPoolView', { + const { queryKey } = qErrorsAllowed(api.projectIpPoolView, { path: { pool: pool.id }, }) queryClient.setQueryData(queryKey, { type: 'success', data: pool }) @@ -247,34 +246,36 @@ export default function NetworkingTab() { // Fetch the floating IPs to show in the "Attach floating IP" modal const { data: ips } = usePrefetchedQuery( - apiq('floatingIpList', { query: { project, limit: ALL_ISH } }) + q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ) // Filter out the IPs that are already attached to an instance const availableIps = useMemo(() => ips.items.filter((ip) => !ip.instanceId), [ips]) - const createNic = useApiMutation('instanceNetworkInterfaceCreate', { + const createNic = useApiMutation(api.instanceNetworkInterfaceCreate, { onSuccess() { queryClient.invalidateEndpoint('instanceNetworkInterfaceList') setCreateModalOpen(false) }, }) - const { mutateAsync: deleteNic } = useApiMutation('instanceNetworkInterfaceDelete', { + const { mutateAsync: deleteNic } = useApiMutation(api.instanceNetworkInterfaceDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('instanceNetworkInterfaceList') addToast(<>Network interface {variables.path.interface} deleted) // prettier-ignore }, }) - const { mutate: editNic } = useApiMutation('instanceNetworkInterfaceUpdate', { + const { mutate: editNic } = useApiMutation(api.instanceNetworkInterfaceUpdate, { onSuccess() { queryClient.invalidateEndpoint('instanceNetworkInterfaceList') }, }) const { data: instance } = usePrefetchedQuery( - apiq('instanceView', { path: { instance: instanceName }, query: { project } }) + q(api.instanceView, { path: { instance: instanceName }, query: { project } }) ) const nics = usePrefetchedQuery( - apiq('instanceNetworkInterfaceList', { query: { ...instanceSelector, limit: ALL_ISH } }) + q(api.instanceNetworkInterfaceList, { + query: { ...instanceSelector, limit: ALL_ISH }, + }) ).data.items const multipleNics = nics.length > 1 @@ -360,10 +361,13 @@ export default function NetworkingTab() { // Attached IPs Table const { data: eips } = usePrefetchedQuery( - apiq('instanceExternalIpList', { path: { instance: instanceName }, query: { project } }) + q(api.instanceExternalIpList, { + path: { instance: instanceName }, + query: { project }, + }) ) - const { mutateAsync: ephemeralIpDetach } = useApiMutation('instanceEphemeralIpDetach', { + const { mutateAsync: ephemeralIpDetach } = useApiMutation(api.instanceEphemeralIpDetach, { onSuccess() { queryClient.invalidateEndpoint('instanceExternalIpList') addToast({ content: 'Ephemeral IP detached' }) @@ -373,7 +377,7 @@ export default function NetworkingTab() { }, }) - const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { + const { mutateAsync: floatingIpDetach } = useApiMutation(api.floatingIpDetach, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('floatingIpList') queryClient.invalidateEndpoint('instanceExternalIpList') diff --git a/app/pages/project/instances/SerialConsolePage.tsx b/app/pages/project/instances/SerialConsolePage.tsx index 298b47424..80014927b 100644 --- a/app/pages/project/instances/SerialConsolePage.tsx +++ b/app/pages/project/instances/SerialConsolePage.tsx @@ -11,8 +11,9 @@ import { Link, type LoaderFunctionArgs } from 'react-router' import { api, - apiq, instanceCan, + instanceSerialConsoleStream, + q, queryClient, usePrefetchedQuery, type Instance, @@ -47,7 +48,7 @@ const statusMessage: Record = { export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await queryClient.prefetchQuery( - apiq('instanceView', { path: { instance }, query: { project } }) + q(api.instanceView, { path: { instance }, query: { project } }) ) return null } @@ -57,7 +58,7 @@ function isStarting(i: Instance | undefined) { } const instanceView = ({ project, instance }: PP.Instance) => - apiq('instanceView', { path: { instance }, query: { project } }) + q(api.instanceView, { path: { instance }, query: { project } }) export const handle = { crumb: 'Serial Console' } @@ -93,7 +94,7 @@ export default function SerialConsolePage() { // TODO: error handling if this connection fails if (!ws.current) { const { project, instance } = instanceSelector - ws.current = api.ws.instanceSerialConsoleStream({ + ws.current = instanceSerialConsoleStream({ secure: window.location.protocol === 'https:', host: window.location.host, path: { instance }, diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index b96d53152..c5772a9de 100644 --- a/app/pages/project/instances/StorageTab.tsx +++ b/app/pages/project/instances/StorageTab.tsx @@ -11,10 +11,11 @@ import type { LoaderFunctionArgs } from 'react-router' import * as R from 'remeda' import { - apiq, + api, diskCan, genName, instanceCan, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -48,10 +49,10 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { // don't bother with page size because this will never paginate. max disks // per instance is 8 // https://github.com/oxidecomputer/omicron/blob/40fc3835/nexus/db-queries/src/db/queries/disk.rs#L16-L21 - queryClient.prefetchQuery(apiq('instanceDiskList', selector)), + queryClient.prefetchQuery(q(api.instanceDiskList, selector)), // This is covered by the InstancePage loader but there's no downside to // being redundant. If it were removed there, we'd still want it here. - queryClient.prefetchQuery(apiq('instanceView', selector)), + queryClient.prefetchQuery(q(api.instanceView, selector)), ]) return null } @@ -87,7 +88,7 @@ export default function StorageTab() { [instanceName, project] ) - const { mutateAsync: detachDisk } = useApiMutation('instanceDiskDetach', { + const { mutateAsync: detachDisk } = useApiMutation(api.instanceDiskDetach, { onSuccess(disk) { queryClient.invalidateEndpoint('instanceDiskList') addToast(<>Disk {disk.name} detached) // prettier-ignore @@ -96,7 +97,7 @@ export default function StorageTab() { addToast({ title: 'Failed to detach disk', content: err.message, variant: 'error' }) }, }) - const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { + const { mutate: createSnapshot } = useApiMutation(api.snapshotCreate, { onSuccess(snapshot) { queryClient.invalidateEndpoint('snapshotList') addToast(<>Snapshot {snapshot.name} created) // prettier-ignore @@ -111,10 +112,10 @@ export default function StorageTab() { }) const { data: instance } = usePrefetchedQuery( - apiq('instanceView', { path: { instance: instanceName }, query: { project } }) + q(api.instanceView, { path: { instance: instanceName }, query: { project } }) ) - const { mutateAsync: instanceUpdate } = useApiMutation('instanceUpdate', { + const { mutateAsync: instanceUpdate } = useApiMutation(api.instanceUpdate, { onSuccess() { queryClient.invalidateEndpoint('instanceView') }, @@ -137,7 +138,7 @@ export default function StorageTab() { [createSnapshot, project] ) - const { data: disks } = usePrefetchedQuery(apiq('instanceDiskList', instancePathQuery)) + const { data: disks } = usePrefetchedQuery(q(api.instanceDiskList, instancePathQuery)) const [bootDisks, otherDisks] = useMemo( () => R.partition(disks.items, (d) => d.id === instance.bootDiskId), @@ -287,7 +288,7 @@ export default function StorageTab() { ] ) - const attachDisk = useApiMutation('instanceDiskAttach', { + const attachDisk = useApiMutation(api.instanceDiskAttach, { onSuccess(disk) { queryClient.invalidateEndpoint('instanceDiskList') setShowDiskCreate(false) diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index e086c879d..8f1859187 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -7,7 +7,7 @@ */ import { useCallback } from 'react' -import { instanceCan, useApiMutation, type Instance } from '@oxide/api' +import { api, instanceCan, useApiMutation, type Instance } from '@oxide/api' import { HL } from '~/components/HL' import { confirmAction } from '~/stores/confirm-action' @@ -39,11 +39,11 @@ 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 { mutateAsync: startInstanceAsync } = useApiMutation('instanceStart', opts) - const { mutateAsync: stopInstanceAsync } = useApiMutation('instanceStop', opts) - const { mutateAsync: rebootInstanceAsync } = useApiMutation('instanceReboot', opts) + const { mutateAsync: startInstanceAsync } = useApiMutation(api.instanceStart, opts) + const { mutateAsync: stopInstanceAsync } = useApiMutation(api.instanceStop, opts) + const { mutateAsync: rebootInstanceAsync } = useApiMutation(api.instanceReboot, opts) // delete has its own - const { mutateAsync: deleteInstanceAsync } = useApiMutation('instanceDelete', { + const { mutateAsync: deleteInstanceAsync } = useApiMutation(api.instanceDelete, { onSuccess: options.onDelete, }) diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index bb10a1595..449e8a28f 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -11,9 +11,10 @@ import { useCallback } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, - apiqErrorsAllowed, + api, getListQFn, + q, + qErrorsAllowed, queryClient, useApiMutation, type Snapshot, @@ -38,7 +39,7 @@ import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' const DiskNameFromId = ({ value }: { value: string }) => { - const { data } = useQuery(apiqErrorsAllowed('diskView', { path: { disk: value } })) + const { data } = useQuery(qErrorsAllowed(api.diskView, { path: { disk: value } })) if (!data) return if (data.type === 'error') return Deleted @@ -55,7 +56,8 @@ const EmptyState = () => ( /> ) -const snapshotList = (project: string) => getListQFn('snapshotList', { query: { project } }) +const snapshotList = (project: string) => + getListQFn(api.snapshotList, { query: { project } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) @@ -71,11 +73,11 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { // (delete disks) are not prefetched here because they are (obviously) not // in the disk list response. queryClient - .fetchQuery(apiq('diskList', { query: { project, limit: 200 } })) + .fetchQuery(q(api.diskList, { query: { project, limit: 200 } })) .then((disks) => { for (const disk of disks.items) { queryClient.setQueryData( - apiqErrorsAllowed('diskView', { path: { disk: disk.id } }).queryKey, + qErrorsAllowed(api.diskView, { path: { disk: disk.id } }).queryKey, { type: 'success', data: disk } ) } @@ -105,7 +107,7 @@ export default function SnapshotsPage() { const { project } = useProjectSelector() const navigate = useNavigate() - const { mutateAsync: deleteSnapshot } = useApiMutation('snapshotDelete', { + const { mutateAsync: deleteSnapshot } = useApiMutation(api.snapshotDelete, { onSuccess() { queryClient.invalidateEndpoint('snapshotList') }, diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index 0aed15fe2..281ee03d1 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -14,8 +14,9 @@ import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/r import { Badge } from '@oxide/design-system/ui' import { - apiq, + api, getListQFn, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -47,9 +48,9 @@ import type * as PP from '~/util/path-params' export const handle = makeCrumb((p) => p.router!) const routerView = ({ project, vpc, router }: PP.VpcRouter) => - apiq('vpcRouterView', { path: { router }, query: { vpc, project } }) + q(api.vpcRouterView, { path: { router }, query: { vpc, project } }) -const routeList = (query: PP.VpcRouter) => getListQFn('vpcRouterRouteList', { query }) +const routeList = (query: PP.VpcRouter) => getListQFn(api.vpcRouterRouteList, { query }) export async function clientLoader({ params }: LoaderFunctionArgs) { const routerSelector = getVpcRouterSelector(params) @@ -89,7 +90,7 @@ export default function RouterPage() { const { project, vpc, router } = useVpcRouterSelector() const { data: routerData } = usePrefetchedQuery(routerView({ project, vpc, router })) - const { mutateAsync: deleteRouterRoute } = useApiMutation('vpcRouterRouteDelete', { + const { mutateAsync: deleteRouterRoute } = useApiMutation(api.vpcRouterRouteDelete, { onSuccess() { queryClient.invalidateEndpoint('vpcRouterRouteList') // We only have the ID, so will show a generic confirmation message @@ -133,7 +134,7 @@ export default function RouterPage() { onActivate: () => { // the edit view has its own loader, but we can make the modal open // instantaneously by preloading the fetch result - const { queryKey } = apiq('vpcRouterRouteView', { + const { queryKey } = q(api.vpcRouterRouteView, { path: { route: routerRoute.name }, query: { project, vpc, router }, }) diff --git a/app/pages/project/vpcs/VpcFirewallRulesTab.tsx b/app/pages/project/vpcs/VpcFirewallRulesTab.tsx index c496d4b54..471aed5b9 100644 --- a/app/pages/project/vpcs/VpcFirewallRulesTab.tsx +++ b/app/pages/project/vpcs/VpcFirewallRulesTab.tsx @@ -11,7 +11,8 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import * as R from 'remeda' import { - apiq, + api, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -101,7 +102,7 @@ const staticColumns = [ colHelper.accessor('timeCreated', Columns.timeCreated), ] -const rulesView = (query: PP.Vpc) => apiq('vpcFirewallRulesView', { query }) +const rulesView = (query: PP.Vpc) => q(api.vpcFirewallRulesView, { query }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc } = getVpcSelector(params) @@ -118,7 +119,7 @@ export default function VpcFirewallRulesTab() { const navigate = useNavigate() - const { mutateAsync: updateRules } = useApiMutation('vpcFirewallRulesUpdate', { + const { mutateAsync: updateRules } = useApiMutation(api.vpcFirewallRulesUpdate, { onSuccess() { queryClient.invalidateEndpoint('vpcFirewallRulesView') }, diff --git a/app/pages/project/vpcs/VpcGatewaysTab.tsx b/app/pages/project/vpcs/VpcGatewaysTab.tsx index 5eb1e19f6..1bebc21aa 100644 --- a/app/pages/project/vpcs/VpcGatewaysTab.tsx +++ b/app/pages/project/vpcs/VpcGatewaysTab.tsx @@ -11,7 +11,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useMemo } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' -import { apiq, getListQFn, queryClient, type InternetGateway } from '~/api' +import { api, getListQFn, q, queryClient, type InternetGateway } from '~/api' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { EmptyCell } from '~/table/cells/EmptyCell' import { IpPoolCell } from '~/table/cells/IpPoolCell' @@ -36,8 +36,10 @@ import { export const handle = { crumb: 'Internet Gateways' } const gatewayList = ({ project, vpc }: PP.Vpc) => - getListQFn('internetGatewayList', { query: { project, vpc, limit: ALL_ISH } }) -const projectIpPoolList = getListQFn('projectIpPoolList', { query: { limit: ALL_ISH } }) + getListQFn(api.internetGatewayList, { query: { project, vpc, limit: ALL_ISH } }) +const projectIpPoolList = getListQFn(api.projectIpPoolList, { + query: { limit: ALL_ISH }, +}) const IpAddressCell = (gatewaySelector: PP.VpcInternetGateway) => { const { data: addresses } = useQuery(gatewayIpAddressList(gatewaySelector).optionsFn()) @@ -81,7 +83,9 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { ), queryClient.fetchQuery(projectIpPoolList.optionsFn()).then((pools) => { for (const pool of pools.items) { - const { queryKey } = apiq('projectIpPoolView', { path: { pool: pool.id } }) + const { queryKey } = q(api.projectIpPoolView, { + path: { pool: pool.id }, + }) queryClient.setQueryData(queryKey, pool) } }), diff --git a/app/pages/project/vpcs/VpcPage.tsx b/app/pages/project/vpcs/VpcPage.tsx index 3853772c7..4b7a5f57f 100644 --- a/app/pages/project/vpcs/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage.tsx @@ -7,7 +7,7 @@ */ import { useNavigate, type LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' @@ -25,7 +25,7 @@ import type * as PP from '~/util/path-params' import { VpcDocsPopover } from './VpcsPage' const vpcView = ({ project, vpc }: PP.Vpc) => - apiq('vpcView', { path: { vpc }, query: { project } }) + q(api.vpcView, { path: { vpc }, query: { project } }) export async function clientLoader({ params }: LoaderFunctionArgs) { await queryClient.prefetchQuery(vpcView(getVpcSelector(params))) @@ -38,7 +38,7 @@ export default function VpcPage() { const { project, vpc: vpcName } = vpcSelector const { data: vpc } = usePrefetchedQuery(vpcView(vpcSelector)) - const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { + const { mutateAsync: deleteVpc } = useApiMutation(api.vpcDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('vpcList') navigate(pb.vpcs({ project })) diff --git a/app/pages/project/vpcs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcRoutersTab.tsx index bb2d38d64..5ad7320de 100644 --- a/app/pages/project/vpcs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcRoutersTab.tsx @@ -9,7 +9,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' -import { apiq, getListQFn, queryClient, useApiMutation, type VpcRouter } from '@oxide/api' +import { api, getListQFn, q, queryClient, useApiMutation, type VpcRouter } from '@oxide/api' import { HL } from '~/components/HL' import { routeFormMessage } from '~/forms/vpc-router-route-common' @@ -27,7 +27,7 @@ import type * as PP from '~/util/path-params' const colHelper = createColumnHelper() -const vpcRouterList = (query: PP.Vpc) => getListQFn('vpcRouterList', { query }) +const vpcRouterList = (query: PP.Vpc) => getListQFn(api.vpcRouterList, { query }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc } = getVpcSelector(params) @@ -62,7 +62,7 @@ export default function VpcRoutersTab() { [vpcSelector] ) - const { mutateAsync: deleteRouter } = useApiMutation('vpcRouterDelete', { + const { mutateAsync: deleteRouter } = useApiMutation(api.vpcRouterDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('vpcRouterList') addToast(<>Router {variables.path.router} deleted) // prettier-ignore @@ -76,7 +76,9 @@ export default function VpcRoutersTab() { onActivate: () => { // the edit view has its own loader, but we can make the modal open // instantaneously by preloading the fetch result - const { queryKey } = apiq('vpcRouterView', { path: { router: router.name } }) + const { queryKey } = q(api.vpcRouterView, { + path: { router: router.name }, + }) queryClient.setQueryData(queryKey, router) navigate(pb.vpcRouterEdit({ project, vpc, router: router.name })) }, diff --git a/app/pages/project/vpcs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcSubnetsTab.tsx index 4659c891b..00f9ce52e 100644 --- a/app/pages/project/vpcs/VpcSubnetsTab.tsx +++ b/app/pages/project/vpcs/VpcSubnetsTab.tsx @@ -9,7 +9,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' -import { getListQFn, queryClient, useApiMutation, type VpcSubnet } from '@oxide/api' +import { api, getListQFn, queryClient, useApiMutation, type VpcSubnet } from '@oxide/api' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -27,7 +27,7 @@ import type * as PP from '~/util/path-params' const colHelper = createColumnHelper() -const subnetList = (params: PP.Vpc) => getListQFn('vpcSubnetList', { query: params }) +const subnetList = (params: PP.Vpc) => getListQFn(api.vpcSubnetList, { query: params }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc } = getVpcSelector(params) @@ -40,7 +40,7 @@ export const handle = { crumb: 'Subnets' } export default function VpcSubnetsTab() { const vpcSelector = useVpcSelector() - const { mutateAsync: deleteSubnet } = useApiMutation('vpcSubnetDelete', { + const { mutateAsync: deleteSubnet } = useApiMutation(api.vpcSubnetDelete, { onSuccess() { queryClient.invalidateEndpoint('vpcSubnetList') // We only have the ID, so will show a generic confirmation message diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index 659d5a02a..44e0302ff 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -10,7 +10,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' -import { apiq, getListQFn, queryClient, useApiMutation, type Vpc } from '@oxide/api' +import { api, getListQFn, q, queryClient, useApiMutation, type Vpc } from '@oxide/api' import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -33,7 +33,7 @@ import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' -const vpcList = (project: string) => getListQFn('vpcList', { query: { project } }) +const vpcList = (project: string) => getListQFn(api.vpcList, { query: { project } }) const EmptyState = () => ( ( ) const FirewallRuleCount = ({ project, vpc }: PP.Vpc) => { - const { data } = useQuery(apiq('vpcFirewallRulesView', { query: { project, vpc } })) + const { data } = useQuery(q(api.vpcFirewallRulesView, { query: { project, vpc } })) if (!data) return // loading @@ -78,7 +78,7 @@ export default function VpcsPage() { const { project } = useProjectSelector() const navigate = useNavigate() - const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { + const { mutateAsync: deleteVpc } = useApiMutation(api.vpcDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('vpcList') addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore @@ -91,7 +91,7 @@ export default function VpcsPage() { label: 'Edit', onActivate() { queryClient.setQueryData( - apiq('vpcView', { path: { vpc: vpc.name }, query: { project } }).queryKey, + q(api.vpcView, { path: { vpc: vpc.name }, query: { project } }).queryKey, vpc ) navigate(pb.vpcEdit({ project, vpc: vpc.name }), { state: vpc }) diff --git a/app/pages/project/vpcs/gateway-data.ts b/app/pages/project/vpcs/gateway-data.ts index d7047ae6f..bff27bfce 100644 --- a/app/pages/project/vpcs/gateway-data.ts +++ b/app/pages/project/vpcs/gateway-data.ts @@ -9,20 +9,22 @@ import { useQueries } from '@tanstack/react-query' import * as R from 'remeda' -import { getListQFn, usePrefetchedQuery } from '~/api' +import { api, getListQFn, usePrefetchedQuery } from '~/api' import { ALL_ISH } from '~/util/consts' import type * as PP from '~/util/path-params' export const routerList = ({ project, vpc }: PP.Vpc) => - getListQFn('vpcRouterList', { query: { project, vpc, limit: ALL_ISH } }) + getListQFn(api.vpcRouterList, { query: { project, vpc, limit: ALL_ISH } }) export const routeList = ({ project, vpc, router }: PP.VpcRouter) => - getListQFn('vpcRouterRouteList', { query: { project, vpc, router, limit: ALL_ISH } }) + getListQFn(api.vpcRouterRouteList, { + query: { project, vpc, router, limit: ALL_ISH }, + }) export const gatewayIpPoolList = ({ project, vpc, gateway }: PP.VpcInternetGateway) => - getListQFn('internetGatewayIpPoolList', { + getListQFn(api.internetGatewayIpPoolList, { query: { project, vpc, gateway, limit: ALL_ISH }, }) export const gatewayIpAddressList = ({ project, vpc, gateway }: PP.VpcInternetGateway) => - getListQFn('internetGatewayIpAddressList', { + getListQFn(api.internetGatewayIpAddressList, { query: { project, vpc, gateway, limit: ALL_ISH }, }) diff --git a/app/pages/project/vpcs/internet-gateway-edit.tsx b/app/pages/project/vpcs/internet-gateway-edit.tsx index fc8f366bb..2e29e984b 100644 --- a/app/pages/project/vpcs/internet-gateway-edit.tsx +++ b/app/pages/project/vpcs/internet-gateway-edit.tsx @@ -12,7 +12,7 @@ import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router' import { Gateway16Icon } from '@oxide/design-system/icons/react' -import { apiq, queryClient, usePrefetchedQuery } from '~/api' +import { api, q, queryClient, usePrefetchedQuery } from '~/api' import { SideModalForm } from '~/components/form/SideModalForm' import { titleCrumb } from '~/hooks/use-crumbs' import { getInternetGatewaySelector, useInternetGatewaySelector } from '~/hooks/use-params' @@ -70,7 +70,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, vpc, gateway } = getInternetGatewaySelector(params) await Promise.all([ queryClient.prefetchQuery( - apiq('internetGatewayView', { + q(api.internetGatewayView, { query: { project, vpc }, path: { gateway }, }) @@ -92,7 +92,7 @@ export default function EditInternetGatewayForm() { const { project, vpc, gateway } = useInternetGatewaySelector() const onDismiss = () => navigate(pb.vpcInternetGateways({ project, vpc })) const { data: internetGateway } = usePrefetchedQuery( - apiq('internetGatewayView', { + q(api.internetGatewayView, { query: { project, vpc }, path: { gateway }, }) diff --git a/app/pages/settings/AccessTokensPage.tsx b/app/pages/settings/AccessTokensPage.tsx index 9cadcd28e..2de731664 100644 --- a/app/pages/settings/AccessTokensPage.tsx +++ b/app/pages/settings/AccessTokensPage.tsx @@ -8,7 +8,13 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' -import { getListQFn, queryClient, useApiMutation, type DeviceAccessToken } from '@oxide/api' +import { + api, + getListQFn, + queryClient, + useApiMutation, + type DeviceAccessToken, +} from '@oxide/api' import { AccessToken16Icon, AccessToken24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -26,7 +32,7 @@ import { TipIcon } from '~/ui/lib/TipIcon' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -const tokenList = () => getListQFn('currentUserAccessTokenList', {}) +const tokenList = () => getListQFn(api.currentUserAccessTokenList, {}) export const handle = makeCrumb('Access Tokens', pb.accessTokens) export async function clientLoader() { @@ -37,7 +43,7 @@ export async function clientLoader() { const colHelper = createColumnHelper() export default function AccessTokensPage() { - const { mutateAsync: deleteToken } = useApiMutation('currentUserAccessTokenDelete', { + const { mutateAsync: deleteToken } = useApiMutation(api.currentUserAccessTokenDelete, { onSuccess: (_data, variables) => { queryClient.invalidateEndpoint('currentUserAccessTokenList') addToast(<>Access token {variables.path.tokenId} deleted) // prettier-ignore diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 160aea767..d40ff1893 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -5,11 +5,12 @@ * * Copyright Oxide Computer Company */ + import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Link, Outlet, useNavigate } from 'react-router' -import { getListQFn, queryClient, useApiMutation, type SshKey } from '@oxide/api' +import { api, getListQFn, queryClient, useApiMutation, type SshKey } from '@oxide/api' import { Key16Icon, Key24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -28,7 +29,7 @@ import { TableActions } from '~/ui/lib/Table' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -const sshKeyList = getListQFn('currentUserSshKeyList', {}) +const sshKeyList = getListQFn(api.currentUserSshKeyList, {}) export const handle = makeCrumb('SSH Keys', pb.sshKeys) export async function clientLoader() { @@ -41,7 +42,7 @@ const colHelper = createColumnHelper() export default function SSHKeysPage() { const navigate = useNavigate() - const { mutateAsync: deleteSshKey } = useApiMutation('currentUserSshKeyDelete', { + const { mutateAsync: deleteSshKey } = useApiMutation(api.currentUserSshKeyDelete, { onSuccess: (_data, variables) => { queryClient.invalidateEndpoint('currentUserSshKeyList') addToast(<>SSH key {variables.path.sshKey} deleted) // prettier-ignore diff --git a/app/pages/system/UpdatePage.tsx b/app/pages/system/UpdatePage.tsx index 7354db6b0..bf57978b9 100644 --- a/app/pages/system/UpdatePage.tsx +++ b/app/pages/system/UpdatePage.tsx @@ -19,7 +19,8 @@ import { import { Badge } from '@oxide/design-system/ui' import { - apiq, + api, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -50,8 +51,8 @@ const SEC = 1000 // ms, obviously const POLL_FAST = 20 * SEC const POLL_SLOW = 120 * SEC -const statusQuery = apiq( - 'systemUpdateStatus', +const statusQuery = q( + api.systemUpdateStatus, {}, { refetchInterval({ state: { data: status } }) { @@ -69,7 +70,9 @@ const statusQuery = apiq( }, } ) -const reposQuery = apiq('systemUpdateRepositoryList', { query: { limit: ALL_ISH } }) +const reposQuery = q(api.systemUpdateRepositoryList, { + query: { limit: ALL_ISH }, +}) const refreshData = () => Promise.all([ @@ -106,7 +109,7 @@ export default function UpdatePage() { const { data: status } = usePrefetchedQuery(statusQuery) const { data: repos } = usePrefetchedQuery(reposQuery) - const { mutateAsync: setTargetRelease } = useApiMutation('targetReleaseUpdate', { + const { mutateAsync: setTargetRelease } = useApiMutation(api.targetReleaseUpdate, { onSuccess() { refreshData() addToast({ content: 'Target release updated' }) diff --git a/app/pages/system/UtilizationPage.tsx b/app/pages/system/UtilizationPage.tsx index 418fba536..89501a559 100644 --- a/app/pages/system/UtilizationPage.tsx +++ b/app/pages/system/UtilizationPage.tsx @@ -10,6 +10,7 @@ import { useIsFetching } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { + api, FLEET_ID, getListQFn, queryClient, @@ -37,10 +38,10 @@ import { round } from '~/util/math' import { pb } from '~/util/path-builder' import { bytesToGiB, bytesToTiB } from '~/util/units' -const siloList = getListQFn('siloList', { +const siloList = getListQFn(api.siloList, { query: { limit: ALL_ISH }, }) -const siloUtilList = getListQFn('siloUtilizationList', { +const siloUtilList = getListQFn(api.siloUtilizationList, { query: { limit: ALL_ISH }, }) diff --git a/app/pages/system/inventory/DisksTab.tsx b/app/pages/system/inventory/DisksTab.tsx index 65d3ffcea..a7379d4d0 100644 --- a/app/pages/system/inventory/DisksTab.tsx +++ b/app/pages/system/inventory/DisksTab.tsx @@ -8,6 +8,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { + api, getListQFn, queryClient, type PhysicalDisk, @@ -38,7 +39,7 @@ const EmptyState = () => ( /> ) -const diskList = getListQFn('physicalDiskList', {}) +const diskList = getListQFn(api.physicalDiskList, {}) export async function clientLoader() { await queryClient.prefetchQuery(diskList.optionsFn()) diff --git a/app/pages/system/inventory/SledsTab.tsx b/app/pages/system/inventory/SledsTab.tsx index 543c55069..881041ed1 100644 --- a/app/pages/system/inventory/SledsTab.tsx +++ b/app/pages/system/inventory/SledsTab.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper } from '@tanstack/react-table' -import { getListQFn, queryClient, type Sled } from '@oxide/api' +import { api, getListQFn, queryClient, type Sled } from '@oxide/api' import { Servers24Icon } from '@oxide/design-system/icons/react' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -17,7 +17,7 @@ import { pb } from '~/util/path-builder' import { ProvisionPolicyBadge, SledKindBadge, SledStateBadge } from './sled/SledBadges' -const sledList = getListQFn('sledList', {}) +const sledList = getListQFn(api.sledList, {}) export async function clientLoader() { await queryClient.fetchQuery(sledList.optionsFn()) diff --git a/app/pages/system/inventory/sled/SledInstancesTab.tsx b/app/pages/system/inventory/sled/SledInstancesTab.tsx index 6440767e6..557a5fd5a 100644 --- a/app/pages/system/inventory/sled/SledInstancesTab.tsx +++ b/app/pages/system/inventory/sled/SledInstancesTab.tsx @@ -9,7 +9,7 @@ import { createColumnHelper } from '@tanstack/react-table' import type { LoaderFunctionArgs } from 'react-router' import * as R from 'remeda' -import { getListQFn, queryClient, type SledInstance } from '@oxide/api' +import { api, getListQFn, queryClient, type SledInstance } from '@oxide/api' import { Instances24Icon } from '@oxide/design-system/icons/react' import { InstanceStateBadge } from '~/components/StateBadge' @@ -21,7 +21,7 @@ import { useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' const sledInstanceList = (sledId: string) => - getListQFn('sledInstanceList', { path: { sledId } }) + getListQFn(api.sledInstanceList, { path: { sledId } }) const EmptyState = () => { return ( diff --git a/app/pages/system/inventory/sled/SledPage.tsx b/app/pages/system/inventory/sled/SledPage.tsx index e782f3bb4..f60dfc91c 100644 --- a/app/pages/system/inventory/sled/SledPage.tsx +++ b/app/pages/system/inventory/sled/SledPage.tsx @@ -8,7 +8,7 @@ import { filesize } from 'filesize' import type { LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api' +import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { Servers24Icon } from '@oxide/design-system/icons/react' import { RouteTabs, Tab } from '~/components/RouteTabs' @@ -22,7 +22,7 @@ import type * as PP from '~/util/path-params' import { ProvisionPolicyBadge, SledKindBadge, SledStateBadge } from './SledBadges' -const sledView = ({ sledId }: PP.Sled) => apiq('sledView', { path: { sledId } }) +const sledView = ({ sledId }: PP.Sled) => q(api.sledView, { path: { sledId } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = requireSledParams(params) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 159b565d7..ca3a6260d 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -13,8 +13,9 @@ import { useForm } from 'react-hook-form' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, + api, getListQFn, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -54,15 +55,15 @@ import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' -const ipPoolView = ({ pool }: PP.IpPool) => apiq('ipPoolView', { path: { pool } }) +const ipPoolView = ({ pool }: PP.IpPool) => q(api.ipPoolView, { path: { pool } }) const ipPoolUtilizationView = ({ pool }: PP.IpPool) => - apiq('ipPoolUtilizationView', { path: { pool } }) + q(api.ipPoolUtilizationView, { path: { pool } }) const ipPoolSiloList = ({ pool }: PP.IpPool) => - getListQFn('ipPoolSiloList', { path: { pool } }) + getListQFn(api.ipPoolSiloList, { path: { pool } }) const ipPoolRangeList = ({ pool }: PP.IpPool) => - getListQFn('ipPoolRangeList', { path: { pool } }) -const siloList = apiq('siloList', { query: { limit: 200 } }) -const siloView = ({ silo }: PP.Silo) => apiq('siloView', { path: { silo } }) + getListQFn(api.ipPoolRangeList, { path: { pool } }) +const siloList = q(api.siloList, { query: { limit: 200 } }) +const siloView = ({ silo }: PP.Silo) => q(api.siloView, { path: { silo } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getIpPoolSelector(params) @@ -91,7 +92,7 @@ export default function IpPoolpage() { const { data: pool } = usePrefetchedQuery(ipPoolView(poolSelector)) const { data: ranges } = usePrefetchedQuery(ipPoolRangeList(poolSelector).optionsFn()) const navigate = useNavigate() - const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { + const { mutateAsync: deletePool } = useApiMutation(api.ipPoolDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('ipPoolList') navigate(pb.ipPools()) @@ -186,7 +187,7 @@ const ipRangesStaticCols = [ function IpRangesTable() { const { pool } = useIpPoolSelector() - const { mutateAsync: removeRange } = useApiMutation('ipPoolRangeRemove', { + const { mutateAsync: removeRange } = useApiMutation(api.ipPoolRangeRemove, { onSuccess() { queryClient.invalidateEndpoint('ipPoolRangeList') queryClient.invalidateEndpoint('ipPoolUtilizationView') @@ -245,7 +246,7 @@ function IpRangesTable() { } function SiloNameFromId({ value: siloId }: { value: string }) { - const { data: silo } = useQuery(apiq('siloView', { path: { silo: siloId } })) + const { data: silo } = useQuery(q(api.siloView, { path: { silo: siloId } })) if (!silo) return @@ -277,7 +278,7 @@ const silosStaticCols = [ function LinkedSilosTable() { const poolSelector = useIpPoolSelector() - const { mutateAsync: unlinkSilo } = useApiMutation('ipPoolSiloUnlink', { + const { mutateAsync: unlinkSilo } = useApiMutation(api.ipPoolSiloUnlink, { onSuccess() { queryClient.invalidateEndpoint('ipPoolSiloList') }, @@ -355,7 +356,7 @@ function LinkSiloModal({ onDismiss }: { onDismiss: () => void }) { const { pool } = useIpPoolSelector() const { control, handleSubmit } = useForm({ defaultValues }) - const linkSilo = useApiMutation('ipPoolSiloLink', { + const linkSilo = useApiMutation(api.ipPoolSiloLink, { onSuccess() { queryClient.invalidateEndpoint('ipPoolSiloList') }, @@ -371,9 +372,9 @@ function LinkSiloModal({ onDismiss }: { onDismiss: () => void }) { } const linkedSilos = useQuery( - apiq('ipPoolSiloList', { path: { pool }, query: { limit: ALL_ISH } }) + q(api.ipPoolSiloList, { path: { pool }, query: { limit: ALL_ISH } }) ) - const allSilos = useQuery(apiq('siloList', { query: { limit: ALL_ISH } })) + const allSilos = useQuery(q(api.siloList, { query: { limit: ALL_ISH } })) // in order to get the list of remaining unlinked silos, we have to get the // list of all silos and remove the already linked ones diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index f41778660..4d9423c9b 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -11,7 +11,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Outlet, useNavigate } from 'react-router' -import { apiq, getListQFn, queryClient, useApiMutation, type IpPool } from '@oxide/api' +import { api, getListQFn, q, queryClient, useApiMutation, type IpPool } from '@oxide/api' import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' @@ -44,7 +44,7 @@ const EmptyState = () => ( ) function UtilizationCell({ pool }: { pool: string }) { - const { data } = useQuery(apiq('ipPoolUtilizationView', { path: { pool } })) + const { data } = useQuery(q(api.ipPoolUtilizationView, { path: { pool } })) if (!data) return return (
@@ -71,7 +71,7 @@ const staticColumns = [ colHelper.accessor('timeCreated', Columns.timeCreated), ] -const ipPoolList = getListQFn('ipPoolList', {}) +const ipPoolList = getListQFn(api.ipPoolList, {}) export async function clientLoader() { await queryClient.prefetchQuery(ipPoolList.optionsFn()) @@ -83,7 +83,7 @@ export const handle = { crumb: 'IP Pools' } export default function IpPoolsPage() { const navigate = useNavigate() - const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { + const { mutateAsync: deletePool } = useApiMutation(api.ipPoolDelete, { onSuccess(_data, variables) { queryClient.invalidateEndpoint('ipPoolList') addToast(<>Pool {variables.path.pool} deleted) // prettier-ignore @@ -97,7 +97,7 @@ export default function IpPoolsPage() { onActivate: () => { // the edit view has its own loader, but we can make the modal open // instantaneously by preloading the fetch result - const ipPoolView = apiq('ipPoolView', { path: { pool: pool.name } }) + const ipPoolView = q(api.ipPoolView, { path: { pool: pool.name } }) queryClient.setQueryData(ipPoolView.queryKey, pool) navigate(pb.ipPoolEdit({ pool: pool.name })) }, diff --git a/app/pages/system/silos/SiloFleetRolesTab.tsx b/app/pages/system/silos/SiloFleetRolesTab.tsx index 3b6c36ed5..3de28b31a 100644 --- a/app/pages/system/silos/SiloFleetRolesTab.tsx +++ b/app/pages/system/silos/SiloFleetRolesTab.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { apiq, usePrefetchedQuery } from '@oxide/api' +import { api, q, usePrefetchedQuery } from '@oxide/api' import { Cloud24Icon, NextArrow12Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' @@ -16,7 +16,7 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' import type * as PP from '~/util/path-params' -const siloView = ({ silo }: PP.Silo) => apiq('siloView', { path: { silo } }) +const siloView = ({ silo }: PP.Silo) => q(api.siloView, { path: { silo } }) export default function SiloFleetRolesTab() { const siloSelector = useSiloSelector() diff --git a/app/pages/system/silos/SiloIdpsTab.tsx b/app/pages/system/silos/SiloIdpsTab.tsx index 4f5411dbe..7a8bd0059 100644 --- a/app/pages/system/silos/SiloIdpsTab.tsx +++ b/app/pages/system/silos/SiloIdpsTab.tsx @@ -12,7 +12,7 @@ import { Outlet, type LoaderFunctionArgs } from 'react-router' import { Cloud24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { getListQFn, queryClient, type IdentityProvider } from '~/api' +import { api, getListQFn, queryClient, type IdentityProvider } from '~/api' import { makeCrumb } from '~/hooks/use-crumbs' import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' import { LinkCell } from '~/table/cells/LinkCell' @@ -29,7 +29,7 @@ const EmptyState = () => ( const colHelper = createColumnHelper() export const siloIdpList = (silo: string) => - getListQFn('siloIdentityProviderList', { query: { silo } }) + getListQFn(api.siloIdentityProviderList, { query: { silo } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { silo } = getSiloSelector(params) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index ba717eb24..2226f3aee 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -12,7 +12,7 @@ import { useCallback, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { type LoaderFunctionArgs } from 'react-router' -import { getListQFn, queryClient, useApiMutation, type SiloIpPool } from '@oxide/api' +import { api, getListQFn, queryClient, useApiMutation, type SiloIpPool } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' import { ComboboxField } from '~/components/form/fields/ComboboxField' @@ -55,14 +55,14 @@ const staticCols = [ }), ] -const allPoolsQuery = getListQFn('ipPoolList', { query: { limit: ALL_ISH } }) +const allPoolsQuery = getListQFn(api.ipPoolList, { query: { limit: ALL_ISH } }) const allSiloPoolsQuery = (silo: string) => - getListQFn('siloIpPoolList', { path: { silo }, query: { limit: ALL_ISH } }) + getListQFn(api.siloIpPoolList, { path: { silo }, query: { limit: ALL_ISH } }) // exported to call in silo page loader export const siloIpPoolsQuery = (silo: string) => - getListQFn('siloIpPoolList', { path: { silo } }) + getListQFn(api.siloIpPoolList, { path: { silo } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { silo } = getSiloSelector(params) @@ -86,12 +86,12 @@ export default function SiloIpPoolsTab() { [allPools] ) - const { mutateAsync: updatePoolLink } = useApiMutation('ipPoolSiloUpdate', { + const { mutateAsync: updatePoolLink } = useApiMutation(api.ipPoolSiloUpdate, { onSuccess() { queryClient.invalidateEndpoint('siloIpPoolList') }, }) - const { mutateAsync: unlinkPool } = useApiMutation('ipPoolSiloUnlink', { + const { mutateAsync: unlinkPool } = useApiMutation(api.ipPoolSiloUnlink, { onSuccess() { queryClient.invalidateEndpoint('siloIpPoolList') // We only have the ID, so will show a generic confirmation message @@ -204,7 +204,7 @@ function LinkPoolModal({ onDismiss }: { onDismiss: () => void }) { const { silo } = useSiloSelector() const { control, handleSubmit } = useForm({ defaultValues }) - const linkPool = useApiMutation('ipPoolSiloLink', { + const linkPool = useApiMutation(api.ipPoolSiloLink, { onSuccess() { queryClient.invalidateEndpoint('siloIpPoolList') }, diff --git a/app/pages/system/silos/SiloPage.tsx b/app/pages/system/silos/SiloPage.tsx index ff12c9a2f..af6875dca 100644 --- a/app/pages/system/silos/SiloPage.tsx +++ b/app/pages/system/silos/SiloPage.tsx @@ -9,7 +9,7 @@ import { type LoaderFunctionArgs } from 'react-router' import { Cloud16Icon, Cloud24Icon } from '@oxide/design-system/icons/react' -import { apiq, queryClient, usePrefetchedQuery } from '~/api' +import { api, q, queryClient, usePrefetchedQuery } from '~/api' import { DocsPopover } from '~/components/DocsPopover' import { RouteTabs, Tab } from '~/components/RouteTabs' import { makeCrumb } from '~/hooks/use-crumbs' @@ -21,7 +21,7 @@ import { pb } from '~/util/path-builder' export async function clientLoader({ params }: LoaderFunctionArgs) { const { silo } = getSiloSelector(params) - await queryClient.prefetchQuery(apiq('siloView', { path: { silo } })) + await queryClient.prefetchQuery(q(api.siloView, { path: { silo } })) return null } @@ -33,7 +33,7 @@ export const handle = makeCrumb( export default function SiloPage() { const siloSelector = useSiloSelector() - const { data: silo } = usePrefetchedQuery(apiq('siloView', { path: siloSelector })) + const { data: silo } = usePrefetchedQuery(q(api.siloView, { path: siloSelector })) return ( <> diff --git a/app/pages/system/silos/SiloQuotasTab.tsx b/app/pages/system/silos/SiloQuotasTab.tsx index fa313f75c..1c03de860 100644 --- a/app/pages/system/silos/SiloQuotasTab.tsx +++ b/app/pages/system/silos/SiloQuotasTab.tsx @@ -12,7 +12,8 @@ import { type LoaderFunctionArgs } from 'react-router' import type { SetNonNullable } from 'type-fest' import { - apiq, + api, + q, queryClient, useApiMutation, usePrefetchedQuery, @@ -28,21 +29,22 @@ import { Message } from '~/ui/lib/Message' import { Table } from '~/ui/lib/Table' import { classed } from '~/util/classed' import { links } from '~/util/links' +import type * as PP from '~/util/path-params' import { bytesToGiB, GiB } from '~/util/units' const Unit = classed.span`ml-1 text-secondary` +const siloUtil = ({ silo }: PP.Silo) => q(api.siloUtilizationView, { path: { silo } }) + export async function clientLoader({ params }: LoaderFunctionArgs) { const { silo } = getSiloSelector(params) - await queryClient.prefetchQuery(apiq('siloUtilizationView', { path: { silo } })) + await queryClient.prefetchQuery(siloUtil({ silo })) return null } export default function SiloQuotasTab() { const { silo } = useSiloSelector() - const { data: utilization } = usePrefetchedQuery( - apiq('siloUtilizationView', { path: { silo } }) - ) + const { data: utilization } = usePrefetchedQuery(siloUtil({ silo })) const { allocated: quotas, provisioned } = utilization @@ -102,11 +104,7 @@ export const handle = makeCrumb('Quotas') function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) { const { silo } = useSiloSelector() - const { data: utilization } = usePrefetchedQuery( - apiq('siloUtilizationView', { - path: { silo: silo }, - }) - ) + const { data: utilization } = usePrefetchedQuery(siloUtil({ silo })) const quotas = utilization.allocated // required because we need to rule out undefined because NumberField hates that @@ -118,7 +116,7 @@ function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) { const form = useForm({ defaultValues }) - const updateQuotas = useApiMutation('siloQuotasUpdate', { + const updateQuotas = useApiMutation(api.siloQuotasUpdate, { onSuccess() { queryClient.invalidateEndpoint('siloUtilizationView') addToast({ content: 'Quotas updated' }) diff --git a/app/pages/system/silos/SiloScimTab.tsx b/app/pages/system/silos/SiloScimTab.tsx index 7266ad3df..a4b96a1c9 100644 --- a/app/pages/system/silos/SiloScimTab.tsx +++ b/app/pages/system/silos/SiloScimTab.tsx @@ -16,7 +16,8 @@ import { AccessToken24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { - apiqErrorsAllowed, + api, + qErrorsAllowed, queryClient, useApiMutation, usePrefetchedQuery, @@ -73,7 +74,7 @@ const EmptyState = () => ( export async function clientLoader({ params }: LoaderFunctionArgs) { const { silo } = getSiloSelector(params) // Use errors-allowed approach so 403s don't throw and break the loader - await queryClient.prefetchQuery(apiqErrorsAllowed('scimTokenList', { query: { silo } })) + await queryClient.prefetchQuery(qErrorsAllowed(api.scimTokenList, { query: { silo } })) return null } @@ -85,7 +86,7 @@ type ModalState = export default function SiloScimTab() { const siloSelector = useSiloSelector() const { data: tokensResult } = usePrefetchedQuery( - apiqErrorsAllowed('scimTokenList', { query: siloSelector }) + qErrorsAllowed(api.scimTokenList, { query: siloSelector }) ) const [modalState, setModalState] = useState(false) @@ -145,7 +146,7 @@ export default function SiloScimTab() { function TokensTable({ tokens }: { tokens: ScimClientBearerToken[] }) { const siloSelector = useSiloSelector() - const deleteToken = useApiMutation('scimTokenDelete', { + const deleteToken = useApiMutation(api.scimTokenDelete, { onSuccess() { queryClient.invalidateEndpoint('scimTokenList') }, @@ -193,7 +194,7 @@ function CreateTokenModal({ onDismiss: () => void onSuccess: (token: ScimClientBearerTokenValue) => void }) { - const createToken = useApiMutation('scimTokenCreate', { + const createToken = useApiMutation(api.scimTokenCreate, { onSuccess(token) { queryClient.invalidateEndpoint('scimTokenList') onSuccess(token) diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index 8fd52406f..c69847974 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -9,7 +9,7 @@ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo } from 'react' import { Outlet, useNavigate } from 'react-router' -import { getListQFn, queryClient, useApiMutation, type Silo } from '@oxide/api' +import { api, getListQFn, queryClient, useApiMutation, type Silo } from '@oxide/api' import { Cloud16Icon, Cloud24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' @@ -31,7 +31,7 @@ import { TableActions } from '~/ui/lib/Table' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -const siloList = () => getListQFn('siloList', {}) +const siloList = () => getListQFn(api.siloList, {}) const EmptyState = () => ( Silo {path.silo} deleted) // prettier-ignore diff --git a/app/table/cells/InstanceLinkCell.tsx b/app/table/cells/InstanceLinkCell.tsx index 7d2cfb0f4..59b6d71f0 100644 --- a/app/table/cells/InstanceLinkCell.tsx +++ b/app/table/cells/InstanceLinkCell.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query' -import { apiq } from '@oxide/api' +import { api, q } from '@oxide/api' import { useProjectSelector } from '~/hooks/use-params' import { pb } from '~/util/path-builder' @@ -19,7 +19,7 @@ import { LinkCell } from './LinkCell' export const InstanceLinkCell = ({ instanceId }: { instanceId?: string | null }) => { const { project } = useProjectSelector() const { data: instance } = useQuery( - apiq('instanceView', { path: { instance: instanceId! } }, { enabled: !!instanceId }) + q(api.instanceView, { path: { instance: instanceId! } }, { enabled: !!instanceId }) ) // has to be after the hooks because hooks can't be executed conditionally diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx index 1b4114b32..dbe8f65ac 100644 --- a/app/table/cells/IpPoolCell.tsx +++ b/app/table/cells/IpPoolCell.tsx @@ -7,14 +7,14 @@ */ import { useQuery } from '@tanstack/react-query' -import { apiqErrorsAllowed } from '~/api' +import { api, qErrorsAllowed } from '~/api' import { Tooltip } from '~/ui/lib/Tooltip' import { EmptyCell, SkeletonCell } from './EmptyCell' export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { const { data: result } = useQuery( - apiqErrorsAllowed('projectIpPoolView', { path: { pool: ipPoolId } }) + qErrorsAllowed(api.projectIpPoolView, { path: { pool: ipPoolId } }) ) if (!result) return // this should essentially never happen, but it's probably better than blowing diff --git a/app/table/cells/RouterLinkCell.tsx b/app/table/cells/RouterLinkCell.tsx index af5487f13..52ae78673 100644 --- a/app/table/cells/RouterLinkCell.tsx +++ b/app/table/cells/RouterLinkCell.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query' -import { apiq } from '@oxide/api' +import { api, q } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' import { useVpcSelector } from '~/hooks/use-params' @@ -20,7 +20,7 @@ import { LinkCell } from './LinkCell' export const RouterLinkCell = ({ routerId }: { routerId?: string | null }) => { const { project, vpc } = useVpcSelector() const { data: router, isError } = useQuery( - apiq('vpcRouterView', { path: { router: routerId! } }, { enabled: !!routerId }) + q(api.vpcRouterView, { path: { router: routerId! } }, { enabled: !!routerId }) ) if (!routerId) return // probably not possible but let’s be safe diff --git a/package-lock.json b/package-lock.json index ce5583cb6..074b60ad0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,8 @@ "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/vite": "^4.1.17", - "@tanstack/react-query": "^5.56.2", - "@tanstack/react-query-devtools": "^5.56.2", + "@tanstack/react-query": "^5.90.7", + "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "^8.20.5", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", @@ -5820,9 +5820,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.56.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.56.2.tgz", - "integrity": "sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q==", + "version": "5.90.7", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.7.tgz", + "integrity": "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==", "license": "MIT", "funding": { "type": "github", @@ -5830,9 +5830,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.56.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.56.1.tgz", - "integrity": "sha512-xnp9jq/9dHfSCDmmf+A5DjbIjYqbnnUL2ToqlaaviUQGRTapXQ8J+GxusYUu1IG0vZMaWdiVUA4HRGGZYAUU+A==", + "version": "5.90.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.90.1.tgz", + "integrity": "sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==", "license": "MIT", "funding": { "type": "github", @@ -5840,13 +5840,13 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.56.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.56.2.tgz", - "integrity": "sha512-SR0GzHVo6yzhN72pnRhkEFRAHMsUo5ZPzAxfTMvUxFIDVS6W9LYUp6nXW3fcHVdg0ZJl8opSH85jqahvm6DSVg==", + "version": "5.90.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.7.tgz", + "integrity": "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==", "license": "MIT", "peer": true, "dependencies": { - "@tanstack/query-core": "5.56.2" + "@tanstack/query-core": "5.90.7" }, "funding": { "type": "github", @@ -5857,19 +5857,19 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.56.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.56.2.tgz", - "integrity": "sha512-7nINJtRZZVwhTTyDdMIcSaXo+EHMLYJu1S2e6FskvvD5prx87LlAXXWZDfU24Qm4HjshEtM5lS3HIOszNGblcw==", + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.90.2.tgz", + "integrity": "sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==", "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.56.1" + "@tanstack/query-devtools": "5.90.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.56.2", + "@tanstack/react-query": "^5.90.2", "react": "^18 || ^19" } }, diff --git a/package.json b/package.json index 3d7e86cde..5fefffaba 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ "@react-aria/live-announcer": "^3.3.4", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/vite": "^4.1.17", - "@tanstack/react-query": "^5.56.2", - "@tanstack/react-query-devtools": "^5.56.2", + "@tanstack/react-query": "^5.90.7", + "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-table": "^8.20.5", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", diff --git a/test/e2e/pagination.e2e.ts b/test/e2e/pagination.e2e.ts index 395cde717..a0eb9179f 100644 --- a/test/e2e/pagination.e2e.ts +++ b/test/e2e/pagination.e2e.ts @@ -7,7 +7,7 @@ */ import { expect, test, type Page } from '@playwright/test' -import { PAGE_SIZE } from '~/api/hooks' +import { PAGE_SIZE } from '~/api/client' import { expectScrollTop, scrollTo } from './utils'