diff --git a/app/components/TopBarPicker.tsx b/app/components/TopBarPicker.tsx
index 1aa2fee3b..5f19aeaf5 100644
--- a/app/components/TopBarPicker.tsx
+++ b/app/components/TopBarPicker.tsx
@@ -5,7 +5,7 @@ import { Link, useParams } from 'react-router-dom'
import { useApiQuery } from '@oxide/api'
import { Identicon, Organization16Icon, SelectArrows6Icon, Success12Icon } from '@oxide/ui'
-import { useInstanceParams, useProjectParams, useSiloParams } from 'app/hooks'
+import { useInstanceSelector, useProjectSelector, useSiloParams } from 'app/hooks'
import { pb } from 'app/util/path-builder'
type TopBarPickerItem = {
@@ -122,7 +122,7 @@ export function useSiloSystemPicker(value: 'silo' | 'system') {
// this request in the loader. If that prefetch were removed, fleet viewers
// would see the silo picker pop in when the request resolves, which would be
// bad.
- const { data: systemPolicy } = useApiQuery('systemPolicyView', {})
+ const { data: systemPolicy } = useApiQuery('systemPolicyViewV1', {})
return systemPolicy ? : null
}
@@ -157,7 +157,7 @@ export function SiloPicker() {
const { data } = useApiQuery('siloList', { query: { limit: 10 } })
const items = (data?.items || []).map((silo) => ({
label: silo.name,
- to: pb.silo({ siloName: silo.name }),
+ to: pb.silo({ silo: silo.name }),
}))
return (
@@ -174,10 +174,10 @@ export function SiloPicker() {
export function OrgPicker() {
const { orgName } = useParams()
- const { data } = useApiQuery('organizationList', { query: { limit: 20 } })
+ const { data } = useApiQuery('organizationListV1', { query: { limit: 20 } })
const items = (data?.items || []).map(({ name }) => ({
label: name,
- to: pb.projects({ orgName: name }),
+ to: pb.projects({ organization: name }),
}))
return (
@@ -194,18 +194,20 @@ export function OrgPicker() {
export function ProjectPicker() {
// picker only shows up when a project is in scope
- const { orgName, projectName } = useProjectParams()
- const { data } = useApiQuery('projectList', { path: { orgName }, query: { limit: 20 } })
+ const { organization, project } = useProjectSelector()
+ const { data } = useApiQuery('projectListV1', {
+ query: { organization, limit: 20 },
+ })
const items = (data?.items || []).map(({ name }) => ({
label: name,
- to: pb.instances({ orgName, projectName: name }),
+ to: pb.instances({ organization, project: name }),
}))
return (
@@ -214,21 +216,20 @@ export function ProjectPicker() {
export function InstancePicker() {
// picker only shows up when an instance is in scope
- const { orgName, projectName, instanceName } = useInstanceParams()
- const { data } = useApiQuery('instanceList', {
- path: { orgName, projectName },
- query: { limit: 50 },
+ const { organization, project, instance } = useInstanceSelector()
+ const { data } = useApiQuery('instanceListV1', {
+ query: { organization, project, limit: 50 },
})
const items = (data?.items || []).map(({ name }) => ({
label: name,
- to: pb.instance({ orgName, projectName, instanceName: name }),
+ to: pb.instance({ organization, project, instance: name }),
}))
return (
diff --git a/app/components/form/fields/SubnetListbox.tsx b/app/components/form/fields/SubnetListbox.tsx
index 5cc122727..f4359a572 100644
--- a/app/components/form/fields/SubnetListbox.tsx
+++ b/app/components/form/fields/SubnetListbox.tsx
@@ -3,7 +3,7 @@ import { useWatch } from 'react-hook-form'
import { useApiQuery } from '@oxide/api'
-import { useRequiredParams } from 'app/hooks'
+import { useProjectSelector } from 'app/hooks'
import type { ListboxFieldProps } from './ListboxField'
import { ListboxField } from './ListboxField'
@@ -26,7 +26,7 @@ export function SubnetListbox<
TFieldValues extends FieldValues,
TName extends FieldPath
>({ vpcNameField, control, ...fieldProps }: SubnetListboxProps) {
- const pathParams = useRequiredParams('orgName', 'projectName')
+ const projectSelector = useProjectSelector()
const [vpcName] = useWatch({ control, name: [vpcNameField] })
@@ -36,8 +36,8 @@ export function SubnetListbox<
// TODO: error handling other than fallback to empty list?
const subnets =
useApiQuery(
- 'vpcSubnetList',
- { path: { ...pathParams, vpcName } },
+ 'vpcSubnetListV1',
+ { query: { ...projectSelector, vpc: vpcName } },
{
enabled: vpcExists,
useErrorBoundary: false,
diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx
index 0dcaf8f83..02f730fc9 100644
--- a/app/forms/disk-attach.tsx
+++ b/app/forms/disk-attach.tsx
@@ -1,10 +1,11 @@
+import { useParams } from 'react-router-dom'
import invariant from 'tiny-invariant'
import type { Disk, DiskIdentifier } from '@oxide/api'
import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api'
import { ListboxField, SideModalForm } from 'app/components/form'
-import { useAllParams } from 'app/hooks'
+import { useProjectSelector } from 'app/hooks'
const defaultValues = { name: '' }
@@ -21,13 +22,18 @@ export function AttachDiskSideModalForm({
onDismiss,
}: AttachDiskProps) {
const queryClient = useApiQueryClient()
- const { orgName, projectName, instanceName } = useAllParams('orgName', 'projectName')
+ // instance name undefined when this form is called from DisksTableField on
+ // instance create, which passes in its own onSubmit, bypassing the attachDisk mutation
+ const { instanceName } = useParams()
+ const projectSelector = useProjectSelector()
- const attachDisk = useApiMutation('instanceDiskAttach', {
+ // TODO: pass in this mutation from outside so we don't have to do the instanceName check
+ const attachDisk = useApiMutation('instanceDiskAttachV1', {
onSuccess(data) {
invariant(instanceName, 'instanceName is required')
- queryClient.invalidateQueries('instanceDiskList', {
- path: { orgName, projectName, instanceName },
+ queryClient.invalidateQueries('instanceDiskListV1', {
+ path: { instance: instanceName },
+ query: projectSelector,
})
onSuccess?.(data)
onDismiss()
@@ -39,7 +45,7 @@ export function AttachDiskSideModalForm({
// click in
// TODO: error handling
const detachedDisks =
- useApiQuery('diskList', { path: { orgName, projectName } }).data?.items.filter(
+ useApiQuery('diskListV1', { query: projectSelector }).data?.items.filter(
(d) => d.state.state === 'detached'
) || []
@@ -53,8 +59,9 @@ export function AttachDiskSideModalForm({
(({ name }) => {
invariant(instanceName, 'instanceName is required')
attachDisk.mutate({
- path: { orgName, projectName, instanceName },
- body: { name },
+ path: { instance: instanceName },
+ query: projectSelector,
+ body: { disk: name },
})
})
}
diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx
index 3cacc7079..0266481db 100644
--- a/app/forms/disk-create.tsx
+++ b/app/forms/disk-create.tsx
@@ -13,7 +13,7 @@ import {
RadioField,
SideModalForm,
} from 'app/components/form'
-import { useRequiredParams, useToast } from 'app/hooks'
+import { useProjectSelector, useToast } from 'app/hooks'
const defaultValues: DiskCreate = {
name: '',
@@ -46,13 +46,13 @@ export function CreateDiskSideModalForm({
onDismiss,
}: CreateSideModalFormProps) {
const queryClient = useApiQueryClient()
- const pathParams = useRequiredParams('orgName', 'projectName')
+ const projectSelector = useProjectSelector()
const addToast = useToast()
const navigate = useNavigate()
- const createDisk = useApiMutation('diskCreate', {
+ const createDisk = useApiMutation('diskCreateV1', {
onSuccess(data) {
- queryClient.invalidateQueries('diskList', { path: pathParams })
+ queryClient.invalidateQueries('diskListV1', { query: projectSelector })
addToast({
icon: ,
title: 'Success!',
@@ -71,7 +71,7 @@ export function CreateDiskSideModalForm({
onDismiss={() => onDismiss(navigate)}
onSubmit={({ size, ...rest }) => {
const body = { size: size * GiB, ...rest }
- onSubmit ? onSubmit(body) : createDisk.mutate({ path: pathParams, body })
+ onSubmit ? onSubmit(body) : createDisk.mutate({ query: projectSelector, body })
}}
loading={createDisk.isLoading}
submitError={createDisk.error}
diff --git a/app/forms/idp-create.tsx b/app/forms/idp-create.tsx
index 6c3f781a7..243709979 100644
--- a/app/forms/idp-create.tsx
+++ b/app/forms/idp-create.tsx
@@ -43,7 +43,7 @@ export function CreateIdpSideModalForm() {
const { siloName } = useSiloParams()
- const onDismiss = () => navigate(pb.silo({ siloName }))
+ const onDismiss = () => navigate(pb.silo({ silo: siloName }))
const createIdp = useApiMutation('samlIdentityProviderCreate', {
onSuccess() {
diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx
index a6744783a..c83e6bcf2 100644
--- a/app/forms/instance-create.tsx
+++ b/app/forms/instance-create.tsx
@@ -3,10 +3,13 @@ import invariant from 'tiny-invariant'
import type { SetRequired } from 'type-fest'
import type { InstanceCreate } from '@oxide/api'
-import { apiQueryClient } from '@oxide/api'
-import { genName } from '@oxide/api'
-import { useApiQuery } from '@oxide/api'
-import { useApiMutation, useApiQueryClient } from '@oxide/api'
+import {
+ apiQueryClient,
+ genName,
+ useApiMutation,
+ useApiQuery,
+ useApiQueryClient,
+} from '@oxide/api'
import {
Divider,
Instances24Icon,
@@ -31,7 +34,7 @@ import {
RadioFieldDyn,
TextField,
} from 'app/components/form'
-import { useRequiredParams, useToast } from 'app/hooks'
+import { useProjectSelector, useToast } from 'app/hooks'
import { pb } from 'app/util/path-builder'
export type InstanceCreateInput = Assign<
@@ -76,17 +79,17 @@ CreateInstanceForm.loader = async () => {
export function CreateInstanceForm() {
const queryClient = useApiQueryClient()
const addToast = useToast()
- const pageParams = useRequiredParams('orgName', 'projectName')
+ const projectSelector = useProjectSelector()
const navigate = useNavigate()
- const createInstance = useApiMutation('instanceCreate', {
+ const createInstance = useApiMutation('instanceCreateV1', {
onSuccess(instance) {
// refetch list of instances
- queryClient.invalidateQueries('instanceList', { path: pageParams })
+ queryClient.invalidateQueries('instanceListV1', { query: projectSelector })
// avoid the instance fetch when the instance page loads since we have the data
queryClient.setQueryData(
- 'instanceView',
- { path: { ...pageParams, instanceName: instance.name } },
+ 'instanceViewV1',
+ { path: { instance: instance.name }, query: projectSelector },
instance
)
addToast({
@@ -94,7 +97,7 @@ export function CreateInstanceForm() {
title: 'Success!',
content: 'Your instance has been created.',
})
- navigate(pb.instancePage({ ...pageParams, instanceName: instance.name }))
+ navigate(pb.instancePage({ ...projectSelector, instance: instance.name }))
},
})
@@ -128,11 +131,11 @@ export function CreateInstanceForm() {
const bootDiskName = values.bootDiskName || genName(values.name, image.name)
createInstance.mutate({
- path: pageParams,
+ query: projectSelector,
body: {
name: values.name,
hostname: values.hostname || values.name,
- description: `An instance in project: ${pageParams.projectName}`,
+ description: `An instance in project: ${projectSelector.project}`,
memory: instance.memory * GiB,
ncpus: instance.ncpus,
disks: [
@@ -263,7 +266,7 @@ export function CreateInstanceForm() {
Create instance
- navigate(pb.instances(pageParams))} />
+ navigate(pb.instances(projectSelector))} />
>
)}
diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx
index 73335d624..667b70225 100644
--- a/app/forms/network-interface-create.tsx
+++ b/app/forms/network-interface-create.tsx
@@ -1,9 +1,9 @@
import { useMemo } from 'react'
+import { useParams } from 'react-router-dom'
import invariant from 'tiny-invariant'
import type { NetworkInterfaceCreate } from '@oxide/api'
-import { useApiQuery } from '@oxide/api'
-import { useApiMutation, useApiQueryClient } from '@oxide/api'
+import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api'
import { Divider } from '@oxide/ui'
import {
@@ -14,7 +14,7 @@ import {
SubnetListbox,
TextField,
} from 'app/components/form'
-import { useAllParams } from 'app/hooks'
+import { useProjectSelector } from 'app/hooks'
const defaultValues: NetworkInterfaceCreate = {
name: '',
@@ -34,19 +34,21 @@ export default function CreateNetworkInterfaceForm({
onDismiss,
}: CreateNetworkInterfaceFormProps) {
const queryClient = useApiQueryClient()
- const { orgName, projectName, instanceName } = useAllParams('orgName', 'projectName')
+ const { instanceName } = useParams()
+ const projectSelector = useProjectSelector()
- const createNetworkInterface = useApiMutation('instanceNetworkInterfaceCreate', {
+ // TODO: pass in this mutation from outside so we don't have to do the instanceName check
+ const createNetworkInterface = useApiMutation('instanceNetworkInterfaceCreateV1', {
onSuccess() {
invariant(instanceName, 'instanceName is required when posting a network interface')
- queryClient.invalidateQueries('instanceNetworkInterfaceList', {
- path: { instanceName, projectName, orgName },
+ queryClient.invalidateQueries('instanceNetworkInterfaceListV1', {
+ query: { instance: instanceName, ...projectSelector },
})
onDismiss()
},
})
- const { data: vpcsData } = useApiQuery('vpcList', { path: { orgName, projectName } })
+ const { data: vpcsData } = useApiQuery('vpcListV1', { query: projectSelector })
const vpcs = useMemo(() => vpcsData?.items || [], [vpcsData])
return (
@@ -64,7 +66,7 @@ export default function CreateNetworkInterfaceForm({
)
createNetworkInterface.mutate({
- path: { instanceName, projectName, orgName },
+ query: { ...projectSelector, instance: instanceName },
body,
})
})
diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx
index 452b58035..a201fa010 100644
--- a/app/forms/network-interface-edit.tsx
+++ b/app/forms/network-interface-edit.tsx
@@ -5,7 +5,7 @@ import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { pick } from '@oxide/util'
import { DescriptionField, NameField, SideModalForm } from 'app/components/form'
-import { useInstanceParams } from 'app/hooks'
+import { useInstanceSelector } from 'app/hooks'
type EditNetworkInterfaceFormProps = {
editing: NetworkInterface
@@ -17,13 +17,16 @@ export default function EditNetworkInterfaceForm({
editing,
}: EditNetworkInterfaceFormProps) {
const queryClient = useApiQueryClient()
- const { orgName, projectName, instanceName } = useInstanceParams()
+ const instanceSelector = useInstanceSelector()
- const editNetworkInterface = useApiMutation('instanceNetworkInterfaceUpdate', {
+ const editNetworkInterface = useApiMutation('instanceNetworkInterfaceUpdateV1', {
onSuccess() {
- invariant(instanceName, 'instanceName is required when posting a network interface')
- queryClient.invalidateQueries('instanceNetworkInterfaceList', {
- path: { orgName, projectName, instanceName },
+ invariant(
+ instanceSelector.instance,
+ 'instanceName is required when posting a network interface'
+ )
+ queryClient.invalidateQueries('instanceNetworkInterfaceListV1', {
+ query: instanceSelector,
})
onDismiss()
},
@@ -40,7 +43,8 @@ export default function EditNetworkInterfaceForm({
onSubmit={(body) => {
const interfaceName = defaultValues.name
editNetworkInterface.mutate({
- path: { orgName, projectName, instanceName, interfaceName },
+ path: { interface: interfaceName },
+ query: instanceSelector,
body,
})
}}
diff --git a/app/forms/org-access.tsx b/app/forms/org-access.tsx
index be253aa91..ec3d77c50 100644
--- a/app/forms/org-access.tsx
+++ b/app/forms/org-access.tsx
@@ -6,20 +6,20 @@ import {
} from '@oxide/api'
import { ListboxField, SideModalForm } from 'app/components/form'
-import { useRequiredParams } from 'app/hooks'
+import { useOrgSelector } from 'app/hooks'
import type { AddRoleModalProps, EditRoleModalProps } from './access-util'
import { actorToItem, defaultValues, roleItems } from './access-util'
export function OrgAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) {
- const orgParams = useRequiredParams('orgName')
+ const { organization } = useOrgSelector()
const actors = useActorsNotInPolicy(policy)
const queryClient = useApiQueryClient()
- const updatePolicy = useApiMutation('organizationPolicyUpdate', {
+ const updatePolicy = useApiMutation('organizationPolicyUpdateV1', {
onSuccess: () => {
- queryClient.invalidateQueries('organizationPolicyView', { path: orgParams })
+ queryClient.invalidateQueries('organizationPolicyViewV1', { path: { organization } })
onDismiss()
},
})
@@ -39,7 +39,7 @@ export function OrgAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPro
const identityType = actors.find((a) => a.id === identityId)!.identityType
updatePolicy.mutate({
- path: orgParams,
+ path: { organization },
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
@@ -76,12 +76,12 @@ export function OrgAccessEditUserSideModal({
policy,
defaultValues,
}: EditRoleModalProps) {
- const orgParams = useRequiredParams('orgName')
+ const { organization } = useOrgSelector()
const queryClient = useApiQueryClient()
- const updatePolicy = useApiMutation('organizationPolicyUpdate', {
+ const updatePolicy = useApiMutation('organizationPolicyUpdateV1', {
onSuccess: () => {
- queryClient.invalidateQueries('organizationPolicyView', { path: orgParams })
+ queryClient.invalidateQueries('organizationPolicyViewV1', { path: { organization } })
onDismiss()
},
})
@@ -94,7 +94,7 @@ export function OrgAccessEditUserSideModal({
formOptions={{ defaultValues }}
onSubmit={({ roleName }) => {
updatePolicy.mutate({
- path: orgParams,
+ path: { organization },
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
diff --git a/app/forms/org-create.tsx b/app/forms/org-create.tsx
index 550b776d1..fb71549b2 100644
--- a/app/forms/org-create.tsx
+++ b/app/forms/org-create.tsx
@@ -18,18 +18,21 @@ export function CreateOrgSideModalForm() {
const queryClient = useApiQueryClient()
const addToast = useToast()
- const createOrg = useApiMutation('organizationCreate', {
+ const createOrg = useApiMutation('organizationCreateV1', {
onSuccess(org) {
- queryClient.invalidateQueries('organizationList', {})
+ queryClient.invalidateQueries('organizationListV1', {})
// avoid the org fetch when the org page loads since we have the data
- const orgParams = { orgName: org.name }
- queryClient.setQueryData('organizationView', { path: orgParams }, org)
+ queryClient.setQueryData(
+ 'organizationViewV1',
+ { path: { organization: org.name } },
+ org
+ )
addToast({
icon: ,
title: 'Success!',
content: 'Your organization has been created.',
})
- navigate(pb.projects(orgParams))
+ navigate(pb.projects({ organization: org.name }))
},
})
diff --git a/app/forms/org-edit.tsx b/app/forms/org-edit.tsx
index 646af35d2..78aab6c98 100644
--- a/app/forms/org-edit.tsx
+++ b/app/forms/org-edit.tsx
@@ -5,12 +5,12 @@ import { apiQueryClient, useApiMutation, useApiQuery, useApiQueryClient } from '
import { Success16Icon } from '@oxide/ui'
import { DescriptionField, NameField, SideModalForm } from 'app/components/form'
-import { requireOrgParams, useOrgParams, useToast } from 'app/hooks'
+import { getOrgSelector, useOrgSelector, useToast } from 'app/hooks'
import { pb } from 'app/util/path-builder'
EditOrgSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('organizationView', {
- path: requireOrgParams(params),
+ await apiQueryClient.prefetchQuery('organizationViewV1', {
+ path: getOrgSelector(params),
})
return null
}
@@ -20,17 +20,21 @@ export function EditOrgSideModalForm() {
const addToast = useToast()
const navigate = useNavigate()
- const { orgName } = useOrgParams()
+ const { organization } = useOrgSelector()
const onDismiss = () => navigate(pb.orgs())
- const { data: org } = useApiQuery('organizationView', { path: { orgName } })
+ const { data: org } = useApiQuery('organizationViewV1', { path: { organization } })
- const updateOrg = useApiMutation('organizationUpdate', {
+ const updateOrg = useApiMutation('organizationUpdateV1', {
onSuccess(org) {
- queryClient.invalidateQueries('organizationList', {})
+ queryClient.invalidateQueries('organizationListV1', {})
// avoid the org fetch when the org page loads since we have the data
- queryClient.setQueryData('organizationView', { path: { orgName: org.name } }, org)
+ queryClient.setQueryData(
+ 'organizationViewV1',
+ { path: { organization: org.name } },
+ org
+ )
addToast({
icon: ,
title: 'Success!',
@@ -49,7 +53,7 @@ export function EditOrgSideModalForm() {
onDismiss={onDismiss}
onSubmit={({ name, description }) =>
updateOrg.mutate({
- path: { orgName },
+ path: { organization },
body: { name, description },
})
}
diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx
index 037c925d2..fe1463ba8 100644
--- a/app/forms/project-access.tsx
+++ b/app/forms/project-access.tsx
@@ -4,22 +4,23 @@ import {
useApiMutation,
useApiQueryClient,
} from '@oxide/api'
+import { toPathQuery } from '@oxide/util'
import { ListboxField, SideModalForm } from 'app/components/form'
-import { useRequiredParams } from 'app/hooks'
+import { useProjectSelector } from 'app/hooks'
import type { AddRoleModalProps, EditRoleModalProps } from './access-util'
import { actorToItem, defaultValues, roleItems } from './access-util'
export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) {
- const projectParams = useRequiredParams('orgName', 'projectName')
+ const projectPathQuery = toPathQuery('project', useProjectSelector())
const actors = useActorsNotInPolicy(policy)
const queryClient = useApiQueryClient()
- const updatePolicy = useApiMutation('projectPolicyUpdate', {
+ const updatePolicy = useApiMutation('projectPolicyUpdateV1', {
onSuccess: () => {
- queryClient.invalidateQueries('projectPolicyView', { path: projectParams })
+ queryClient.invalidateQueries('projectPolicyViewV1', projectPathQuery)
onDismiss()
},
})
@@ -38,7 +39,7 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa
const identityType = actors.find((a) => a.id === identityId)!.identityType
updatePolicy.mutate({
- path: projectParams,
+ ...projectPathQuery,
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
@@ -76,12 +77,12 @@ export function ProjectAccessEditUserSideModal({
policy,
defaultValues,
}: EditRoleModalProps) {
- const projectParams = useRequiredParams('orgName', 'projectName')
+ const projectPathQuery = toPathQuery('project', useProjectSelector())
const queryClient = useApiQueryClient()
- const updatePolicy = useApiMutation('projectPolicyUpdate', {
+ const updatePolicy = useApiMutation('projectPolicyUpdateV1', {
onSuccess: () => {
- queryClient.invalidateQueries('projectPolicyView', { path: projectParams })
+ queryClient.invalidateQueries('projectPolicyViewV1', projectPathQuery)
onDismiss()
},
})
@@ -94,7 +95,7 @@ export function ProjectAccessEditUserSideModal({
formOptions={{ defaultValues }}
onSubmit={({ roleName }) => {
updatePolicy.mutate({
- path: projectParams,
+ ...projectPathQuery,
body: updateRole({ identityId, identityType, roleName }, policy),
})
}}
diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx
index 610365e86..4d6c7684f 100644
--- a/app/forms/project-create.tsx
+++ b/app/forms/project-create.tsx
@@ -7,7 +7,7 @@ import { Success16Icon } from '@oxide/ui'
import { DescriptionField, NameField, SideModalForm } from 'app/components/form'
import { pb } from 'app/util/path-builder'
-import { useRequiredParams, useToast } from '../hooks'
+import { useOrgSelector, useToast } from '../hooks'
const defaultValues: ProjectCreate = {
name: '',
@@ -19,23 +19,26 @@ export function CreateProjectSideModalForm() {
const queryClient = useApiQueryClient()
const addToast = useToast()
- const { orgName } = useRequiredParams('orgName')
+ const { organization } = useOrgSelector()
- const onDismiss = () => navigate(pb.projects({ orgName }))
+ const onDismiss = () => navigate(pb.projects({ organization }))
- const createProject = useApiMutation('projectCreate', {
+ const createProject = useApiMutation('projectCreateV1', {
onSuccess(project) {
// refetch list of projects in sidebar
- queryClient.invalidateQueries('projectList', { path: { orgName } })
+ queryClient.invalidateQueries('projectListV1', { query: { organization } })
// avoid the project fetch when the project page loads since we have the data
- const projectParams = { orgName, projectName: project.name }
- queryClient.setQueryData('projectView', { path: projectParams }, project)
+ queryClient.setQueryData(
+ 'projectViewV1',
+ { path: { project: project.name }, query: { organization } },
+ project
+ )
addToast({
icon: ,
title: 'Success!',
content: 'Your project has been created.',
})
- navigate(pb.instances(projectParams))
+ navigate(pb.instances({ organization, project: project.name }))
},
})
@@ -47,7 +50,7 @@ export function CreateProjectSideModalForm() {
onDismiss={onDismiss}
onSubmit={({ name, description }) => {
createProject.mutate({
- path: { orgName },
+ query: { organization },
body: { name, description },
})
}}
diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx
index c8d9a4944..069208b6a 100644
--- a/app/forms/project-edit.tsx
+++ b/app/forms/project-edit.tsx
@@ -3,16 +3,18 @@ import { useNavigate } from 'react-router-dom'
import { apiQueryClient, useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api'
import { Success16Icon } from '@oxide/ui'
+import { toPathQuery } from '@oxide/util'
import { DescriptionField, NameField, SideModalForm } from 'app/components/form'
import { pb } from 'app/util/path-builder'
-import { requireProjectParams, useRequiredParams, useToast } from '../hooks'
+import { getProjectSelector, useProjectSelector, useToast } from '../hooks'
EditProjectSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('projectView', {
- path: requireProjectParams(params),
- })
+ await apiQueryClient.prefetchQuery(
+ 'projectViewV1',
+ toPathQuery('project', getProjectSelector(params))
+ )
return null
}
@@ -21,20 +23,23 @@ export function EditProjectSideModalForm() {
const addToast = useToast()
const navigate = useNavigate()
- const { orgName, projectName } = useRequiredParams('orgName', 'projectName')
+ const projectSelector = useProjectSelector()
+ const projectPathQuery = toPathQuery('project', projectSelector)
+ const { organization } = projectSelector
- const onDismiss = () => navigate(pb.projects({ orgName }))
+ const onDismiss = () => navigate(pb.projects(projectSelector))
- const { data: project } = useApiQuery('projectView', { path: { orgName, projectName } })
+ const { data: project } = useApiQuery('projectViewV1', projectPathQuery)
- const editProject = useApiMutation('projectUpdate', {
+ const editProject = useApiMutation('projectUpdateV1', {
onSuccess(project) {
// refetch list of projects in sidebar
- queryClient.invalidateQueries('projectList', { path: { orgName } })
+ // TODO: check this invalidation
+ queryClient.invalidateQueries('projectListV1', { query: { organization } })
// avoid the project fetch when the project page loads since we have the data
queryClient.setQueryData(
- 'projectView',
- { path: { orgName, projectName: project.name } },
+ 'projectViewV1',
+ { path: { project: project.name }, query: { organization } },
project
)
addToast({
@@ -53,10 +58,7 @@ export function EditProjectSideModalForm() {
title="Edit project"
onDismiss={onDismiss}
onSubmit={({ name, description }) => {
- editProject.mutate({
- path: { orgName, projectName },
- body: { name, description },
- })
+ editProject.mutate({ ...projectPathQuery, body: { name, description } })
}}
loading={editProject.isLoading}
submitError={editProject.error}
diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx
index ca638a422..7135fb8af 100644
--- a/app/forms/silo-access.tsx
+++ b/app/forms/silo-access.tsx
@@ -14,9 +14,9 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr
const actors = useActorsNotInPolicy(policy)
const queryClient = useApiQueryClient()
- const updatePolicy = useApiMutation('policyUpdate', {
+ const updatePolicy = useApiMutation('policyUpdateV1', {
onSuccess: () => {
- queryClient.invalidateQueries('policyView', {})
+ queryClient.invalidateQueries('policyViewV1', {})
onDismiss()
},
})
@@ -74,9 +74,9 @@ export function SiloAccessEditUserSideModal({
defaultValues,
}: EditRoleModalProps) {
const queryClient = useApiQueryClient()
- const updatePolicy = useApiMutation('policyUpdate', {
+ const updatePolicy = useApiMutation('policyUpdateV1', {
onSuccess: () => {
- queryClient.invalidateQueries('policyView', {})
+ queryClient.invalidateQueries('policyViewV1', {})
onDismiss()
},
})
diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx
index acf4dccd1..0f186305c 100644
--- a/app/forms/snapshot-create.tsx
+++ b/app/forms/snapshot-create.tsx
@@ -1,9 +1,7 @@
import { useNavigate } from 'react-router-dom'
-import type { PathParams, SnapshotCreate } from '@oxide/api'
-import { useApiQuery } from '@oxide/api'
-import { useApiMutation } from '@oxide/api'
-import { useApiQueryClient } from '@oxide/api'
+import type { PathParams as PP, SnapshotCreate } from '@oxide/api'
+import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api'
import { Success16Icon } from '@oxide/ui'
import {
@@ -12,11 +10,13 @@ import {
NameField,
SideModalForm,
} from 'app/components/form'
-import { useRequiredParams, useToast } from 'app/hooks'
+import { useProjectSelector, useToast } from 'app/hooks'
import { pb } from 'app/util/path-builder'
-const useSnapshotDiskItems = (params: PathParams.Project) => {
- const { data: disks } = useApiQuery('diskList', { path: params, query: { limit: 1000 } })
+const useSnapshotDiskItems = (projectSelector: PP.Project) => {
+ const { data: disks } = useApiQuery('diskListV1', {
+ query: { ...projectSelector, limit: 1000 },
+ })
return (
disks?.items
.filter((disk) => disk.state.state === 'attached')
@@ -32,17 +32,17 @@ const defaultValues: SnapshotCreate = {
export function CreateSnapshotSideModalForm() {
const queryClient = useApiQueryClient()
- const pathParams = useRequiredParams('orgName', 'projectName')
+ const projectSelector = useProjectSelector()
const addToast = useToast()
const navigate = useNavigate()
- const diskItems = useSnapshotDiskItems(pathParams)
+ const diskItems = useSnapshotDiskItems(projectSelector)
- const onDismiss = () => navigate(pb.snapshots(pathParams))
+ const onDismiss = () => navigate(pb.snapshots(projectSelector))
- const createSnapshot = useApiMutation('snapshotCreate', {
+ const createSnapshot = useApiMutation('snapshotCreateV1', {
onSuccess() {
- queryClient.invalidateQueries('snapshotList', { path: pathParams })
+ queryClient.invalidateQueries('snapshotListV1', { query: projectSelector })
addToast({
icon: ,
title: 'Success!',
@@ -59,10 +59,7 @@ export function CreateSnapshotSideModalForm() {
formOptions={{ defaultValues }}
onDismiss={onDismiss}
onSubmit={(values) => {
- createSnapshot.mutate({
- path: pathParams,
- body: values,
- })
+ createSnapshot.mutate({ query: projectSelector, body: values })
}}
submitError={createSnapshot.error}
>
diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx
index 7a9163d49..a18bd06dc 100644
--- a/app/forms/subnet-create.tsx
+++ b/app/forms/subnet-create.tsx
@@ -3,7 +3,7 @@ import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { Divider } from '@oxide/ui'
import { DescriptionField, NameField, SideModalForm, TextField } from 'app/components/form'
-import { useRequiredParams } from 'app/hooks'
+import { useVpcSelector } from 'app/hooks'
const defaultValues: VpcSubnetCreate = {
name: '',
@@ -16,12 +16,12 @@ type CreateSubnetFormProps = {
}
export function CreateSubnetForm({ onDismiss }: CreateSubnetFormProps) {
- const parentNames = useRequiredParams('orgName', 'projectName', 'vpcName')
+ const vpcSelector = useVpcSelector()
const queryClient = useApiQueryClient()
- const createSubnet = useApiMutation('vpcSubnetCreate', {
+ const createSubnet = useApiMutation('vpcSubnetCreateV1', {
onSuccess() {
- queryClient.invalidateQueries('vpcSubnetList', { path: parentNames })
+ queryClient.invalidateQueries('vpcSubnetListV1', { query: vpcSelector })
onDismiss()
},
})
@@ -31,7 +31,7 @@ export function CreateSubnetForm({ onDismiss }: CreateSubnetFormProps) {
title="Create subnet"
formOptions={{ defaultValues }}
onDismiss={onDismiss}
- onSubmit={(body) => createSubnet.mutate({ path: parentNames, body })}
+ onSubmit={(body) => createSubnet.mutate({ query: vpcSelector, body })}
loading={createSubnet.isLoading}
submitError={createSubnet.error}
>
diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx
index 2196c4f69..a23f1af8f 100644
--- a/app/forms/subnet-edit.tsx
+++ b/app/forms/subnet-edit.tsx
@@ -3,7 +3,7 @@ import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { pick } from '@oxide/util'
import { DescriptionField, NameField, SideModalForm } from 'app/components/form'
-import { useRequiredParams } from 'app/hooks'
+import { useVpcSelector } from 'app/hooks'
type EditSubnetFormProps = {
onDismiss: () => void
@@ -11,12 +11,12 @@ type EditSubnetFormProps = {
}
export function EditSubnetForm({ onDismiss, editing }: EditSubnetFormProps) {
- const parentNames = useRequiredParams('orgName', 'projectName', 'vpcName')
+ const vpcSelector = useVpcSelector()
const queryClient = useApiQueryClient()
- const updateSubnet = useApiMutation('vpcSubnetUpdate', {
+ const updateSubnet = useApiMutation('vpcSubnetUpdateV1', {
onSuccess() {
- queryClient.invalidateQueries('vpcSubnetList', { path: parentNames })
+ queryClient.invalidateQueries('vpcSubnetListV1', { query: vpcSelector })
onDismiss()
},
})
@@ -31,7 +31,8 @@ export function EditSubnetForm({ onDismiss, editing }: EditSubnetFormProps) {
formOptions={{ defaultValues }}
onSubmit={(body) => {
updateSubnet.mutate({
- path: { ...parentNames, subnetName: editing.name },
+ path: { subnet: editing.name },
+ query: vpcSelector,
body,
})
}}
diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx
index 5b4bdbb54..209aaf630 100644
--- a/app/forms/vpc-create.tsx
+++ b/app/forms/vpc-create.tsx
@@ -3,9 +3,10 @@ import { useNavigate } from 'react-router-dom'
import type { VpcCreate } from '@oxide/api'
import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { Success16Icon } from '@oxide/ui'
+import { toPathQuery } from '@oxide/util'
import { DescriptionField, NameField, SideModalForm, TextField } from 'app/components/form'
-import { useRequiredParams, useToast } from 'app/hooks'
+import { useProjectSelector, useToast } from 'app/hooks'
import { pb } from 'app/util/path-builder'
const defaultValues: VpcCreate = {
@@ -15,23 +16,23 @@ const defaultValues: VpcCreate = {
}
export function CreateVpcSideModalForm() {
- const parentNames = useRequiredParams('orgName', 'projectName')
+ const projectSelector = useProjectSelector()
const queryClient = useApiQueryClient()
const addToast = useToast()
const navigate = useNavigate()
- const createVpc = useApiMutation('vpcCreate', {
+ const createVpc = useApiMutation('vpcCreateV1', {
onSuccess(vpc) {
- const vpcParams = { ...parentNames, vpcName: vpc.name }
- queryClient.invalidateQueries('vpcList', { path: parentNames })
+ const vpcSelector = { ...projectSelector, vpc: vpc.name }
+ queryClient.invalidateQueries('vpcListV1', { query: projectSelector })
// avoid the vpc fetch when the vpc page loads since we have the data
- queryClient.setQueryData('vpcView', { path: vpcParams }, vpc)
+ queryClient.setQueryData('vpcViewV1', toPathQuery('vpc', vpcSelector), vpc)
addToast({
icon: ,
title: 'Success!',
content: 'Your VPC has been created.',
})
- navigate(pb.vpc(vpcParams))
+ navigate(pb.vpc(vpcSelector))
},
})
@@ -40,8 +41,8 @@ export function CreateVpcSideModalForm() {
id="create-vpc-form"
title="Create VPC"
formOptions={{ defaultValues }}
- onSubmit={(values) => createVpc.mutate({ path: parentNames, body: values })}
- onDismiss={() => navigate(pb.vpcs(parentNames))}
+ onSubmit={(values) => createVpc.mutate({ query: projectSelector, body: values })}
+ onDismiss={() => navigate(pb.vpcs(projectSelector))}
loading={createVpc.isLoading}
submitError={createVpc.error}
>
diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx
index 52a40b66e..5f12131c5 100644
--- a/app/forms/vpc-edit.tsx
+++ b/app/forms/vpc-edit.tsx
@@ -1,38 +1,40 @@
import type { LoaderFunctionArgs } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
-import { useApiQuery } from '@oxide/api'
-import { apiQueryClient, useApiMutation, useApiQueryClient } from '@oxide/api'
+import { apiQueryClient, useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api'
import { Success16Icon } from '@oxide/ui'
+import { pick, toPathQuery } from '@oxide/util'
import { DescriptionField, NameField, SideModalForm } from 'app/components/form'
-import { requireVpcParams, useToast, useVpcParams } from 'app/hooks'
+import { getVpcSelector, useToast, useVpcSelector } from 'app/hooks'
import { pb } from 'app/util/path-builder'
EditVpcSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('vpcView', {
- path: requireVpcParams(params),
- })
+ await apiQueryClient.prefetchQuery(
+ 'vpcViewV1',
+ toPathQuery('vpc', getVpcSelector(params))
+ )
return null
}
export function EditVpcSideModalForm() {
- const vpcParams = useVpcParams()
- const parentNames = { orgName: vpcParams.orgName, projectName: vpcParams.projectName }
+ const vpcSelector = useVpcSelector()
+ const vpcPathQuery = toPathQuery('vpc', vpcSelector)
+ const projectSelector = pick(vpcSelector, 'organization', 'project')
const queryClient = useApiQueryClient()
const addToast = useToast()
const navigate = useNavigate()
- const { data: vpc } = useApiQuery('vpcView', { path: vpcParams })
+ const { data: vpc } = useApiQuery('vpcViewV1', vpcPathQuery)
- const onDismiss = () => navigate(pb.vpcs(parentNames))
+ const onDismiss = () => navigate(pb.vpcs(projectSelector))
- const editVpc = useApiMutation('vpcUpdate', {
+ const editVpc = useApiMutation('vpcUpdateV1', {
async onSuccess(vpc) {
- queryClient.invalidateQueries('vpcList', { path: parentNames })
+ queryClient.invalidateQueries('vpcListV1', { query: projectSelector })
queryClient.setQueryData(
- 'vpcView',
- { path: { ...parentNames, vpcName: vpc.name } },
+ 'vpcViewV1',
+ { path: { vpc: vpc.name }, query: projectSelector },
vpc
)
addToast({
@@ -52,7 +54,7 @@ export function EditVpcSideModalForm() {
onDismiss={onDismiss}
onSubmit={({ name, description, dnsName }) => {
editVpc.mutate({
- path: vpcParams,
+ ...vpcPathQuery,
body: { name, description, dnsName },
})
}}
diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx
index a922ef7ff..7a180241d 100644
--- a/app/forms/vpc-router-create.tsx
+++ b/app/forms/vpc-router-create.tsx
@@ -3,7 +3,7 @@ import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { Success16Icon } from '@oxide/ui'
import { DescriptionField, NameField, SideModalForm } from 'app/components/form'
-import { useRequiredParams, useToast } from 'app/hooks'
+import { useToast, useVpcSelector } from 'app/hooks'
const defaultValues: VpcRouterCreate = {
name: '',
@@ -15,17 +15,17 @@ type CreateVpcRouterFormProps = {
}
export function CreateVpcRouterForm({ onDismiss }: CreateVpcRouterFormProps) {
- const parentNames = useRequiredParams('orgName', 'projectName', 'vpcName')
+ const vpcSelector = useVpcSelector()
const queryClient = useApiQueryClient()
const addToast = useToast()
- const createRouter = useApiMutation('vpcRouterCreate', {
+ const createRouter = useApiMutation('vpcRouterCreateV1', {
onSuccess(router) {
- queryClient.invalidateQueries('vpcRouterList', { path: parentNames })
+ queryClient.invalidateQueries('vpcRouterListV1', { query: vpcSelector })
// avoid the vpc fetch when the vpc page loads since we have the data
queryClient.setQueryData(
- 'vpcRouterView',
- { path: { ...parentNames, routerName: router.name } },
+ 'vpcRouterViewV1',
+ { path: { router: router.name }, query: vpcSelector },
router
)
addToast({
@@ -44,7 +44,7 @@ export function CreateVpcRouterForm({ onDismiss }: CreateVpcRouterFormProps) {
formOptions={{ defaultValues }}
onDismiss={onDismiss}
onSubmit={({ name, description }) =>
- createRouter.mutate({ path: parentNames, body: { name, description } })
+ createRouter.mutate({ query: vpcSelector, body: { name, description } })
}
loading={createRouter.isLoading}
submitError={createRouter.error}
diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx
index af51b2a48..bff5052e0 100644
--- a/app/forms/vpc-router-edit.tsx
+++ b/app/forms/vpc-router-edit.tsx
@@ -3,7 +3,7 @@ import { useApiMutation, useApiQueryClient } from '@oxide/api'
import { pick } from '@oxide/util'
import { DescriptionField, NameField, SideModalForm } from 'app/components/form'
-import { useRequiredParams } from 'app/hooks'
+import { useVpcSelector } from 'app/hooks'
type EditVpcRouterFormProps = {
onDismiss: () => void
@@ -11,12 +11,12 @@ type EditVpcRouterFormProps = {
}
export function EditVpcRouterForm({ onDismiss, editing }: EditVpcRouterFormProps) {
- const parentNames = useRequiredParams('orgName', 'projectName', 'vpcName')
+ const vpcSelector = useVpcSelector()
const queryClient = useApiQueryClient()
- const updateRouter = useApiMutation('vpcRouterUpdate', {
+ const updateRouter = useApiMutation('vpcRouterUpdateV1', {
onSuccess() {
- queryClient.invalidateQueries('vpcRouterList', { path: parentNames })
+ queryClient.invalidateQueries('vpcRouterListV1', { query: vpcSelector })
onDismiss()
},
})
@@ -31,7 +31,8 @@ export function EditVpcRouterForm({ onDismiss, editing }: EditVpcRouterFormProps
onDismiss={onDismiss}
onSubmit={({ name, description }) => {
updateRouter.mutate({
- path: { ...parentNames, routerName: editing.name },
+ path: { router: editing.name },
+ query: vpcSelector,
body: { name, description },
})
}}
diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts
index 6e0dce369..6221763fd 100644
--- a/app/hooks/use-params.ts
+++ b/app/hooks/use-params.ts
@@ -2,6 +2,8 @@ import type { Params } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import invariant from 'tiny-invariant'
+import { toApiSelector } from '@oxide/util'
+
const err = (param: string) =>
`Param '${param}' not found in route. You might be rendering a component under the wrong route.`
@@ -19,22 +21,36 @@ export const requireParams =
return requiredParams as { readonly [k in K]: string }
}
-export const requireOrgParams = requireParams('orgName')
+const requireOrgParams = requireParams('orgName')
export const requireProjectParams = requireParams('orgName', 'projectName')
-export const requireInstanceParams = requireParams('orgName', 'projectName', 'instanceName')
-export const requireVpcParams = requireParams('orgName', 'projectName', 'vpcName')
+const requireInstanceParams = requireParams('orgName', 'projectName', 'instanceName')
+const requireVpcParams = requireParams('orgName', 'projectName', 'vpcName')
export const requireSiloParams = requireParams('siloName')
export const requireSledParams = requireParams('sledId')
export const requireUpdateParams = requireParams('version')
-export const useOrgParams = () => requireOrgParams(useParams())
-export const useProjectParams = () => requireProjectParams(useParams())
-export const useInstanceParams = () => requireInstanceParams(useParams())
-export const useVpcParams = () => requireVpcParams(useParams())
+const useOrgParams = () => requireOrgParams(useParams())
+const useProjectParams = () => requireProjectParams(useParams())
+const useInstanceParams = () => requireInstanceParams(useParams())
+const useVpcParams = () => requireVpcParams(useParams())
export const useSiloParams = () => requireSiloParams(useParams())
export const useSledParams = () => requireSledParams(useParams())
export const useUpdateParams = () => requireUpdateParams(useParams())
+export const getOrgSelector = (p: Readonly>) =>
+ toApiSelector(requireOrgParams(p))
+export const getProjectSelector = (p: Readonly>) =>
+ toApiSelector(requireProjectParams(p))
+export const getInstanceSelector = (p: Readonly>) =>
+ toApiSelector(requireInstanceParams(p))
+export const getVpcSelector = (p: Readonly>) =>
+ toApiSelector(requireVpcParams(p))
+
+export const useOrgSelector = () => toApiSelector(useOrgParams())
+export const useProjectSelector = () => toApiSelector(useProjectParams())
+export const useVpcSelector = () => toApiSelector(useVpcParams())
+export const useInstanceSelector = () => toApiSelector(useInstanceParams())
+
/**
* Wrapper for RR's `useParams` that guarantees (in dev) that the specified
* params are present. No keys besides those specified are present on the result
diff --git a/app/layouts/OrgLayout.tsx b/app/layouts/OrgLayout.tsx
index 27d48ec81..099710702 100644
--- a/app/layouts/OrgLayout.tsx
+++ b/app/layouts/OrgLayout.tsx
@@ -2,14 +2,14 @@ import { Access16Icon, Divider, Folder16Icon, Organization16Icon } from '@oxide/
import { TopBar } from 'app/components/TopBar'
import { OrgPicker, useSiloSystemPicker } from 'app/components/TopBarPicker'
-import { useRequiredParams } from 'app/hooks'
+import { useOrgSelector } from 'app/hooks'
import { pb } from 'app/util/path-builder'
import { DocsLinkItem, NavLinkItem, Sidebar } from '../components/Sidebar'
import { ContentPane, PageContainer } from './helpers'
const OrgLayout = () => {
- const { orgName } = useRequiredParams('orgName')
+ const { organization } = useOrgSelector()
return (
@@ -26,12 +26,12 @@ const OrgLayout = () => {
-
-
+
+
Projects
-
+
Access & IAM
diff --git a/app/layouts/ProjectLayout.tsx b/app/layouts/ProjectLayout.tsx
index a67d0c732..0501993b3 100644
--- a/app/layouts/ProjectLayout.tsx
+++ b/app/layouts/ProjectLayout.tsx
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
-import { matchPath, useLocation, useNavigate } from 'react-router-dom'
+import { matchPath, useLocation, useNavigate, useParams } from 'react-router-dom'
import {
Access16Icon,
@@ -19,7 +19,7 @@ import {
ProjectPicker,
useSiloSystemPicker,
} from 'app/components/TopBarPicker'
-import { useAllParams, useQuickActions } from 'app/hooks'
+import { useProjectSelector, useQuickActions } from 'app/hooks'
import { pb } from 'app/util/path-builder'
import { DocsLinkItem, NavLinkItem, Sidebar } from '../components/Sidebar'
@@ -28,8 +28,9 @@ import { ContentPane, PageContainer } from './helpers'
const ProjectLayout = () => {
const navigate = useNavigate()
// org and project will always be there, instance may not
- const { instanceName, orgName, projectName } = useAllParams('orgName', 'projectName')
- const projectParams = { orgName, projectName }
+ const projectSelector = useProjectSelector()
+ const { project: projectName } = projectSelector
+ const { instanceName } = useParams()
const currentPath = useLocation().pathname
useQuickActions(
useMemo(
@@ -64,7 +65,7 @@ const ProjectLayout = () => {
-
+
Projects
@@ -72,22 +73,22 @@ const ProjectLayout = () => {
-
+
Instances
-
+
Snapshots
-
+
Disks
-
+
Access & IAM
-
+
Images
-
+
Networking
diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx
index 9c82fd019..db375335b 100644
--- a/app/layouts/SystemLayout.tsx
+++ b/app/layouts/SystemLayout.tsx
@@ -31,7 +31,7 @@ import { ContentPane, PageContainer } from './helpers'
*/
SystemLayout.loader = async () => {
const isFleetViewer = await apiQueryClient
- .fetchQuery('systemPolicyView', {})
+ .fetchQuery('systemPolicyViewV1', {})
.then(() => true)
.catch(() => false)
diff --git a/app/layouts/helpers.tsx b/app/layouts/helpers.tsx
index 2911288b1..877845542 100644
--- a/app/layouts/helpers.tsx
+++ b/app/layouts/helpers.tsx
@@ -35,7 +35,7 @@ export const userLoader = async () => {
// Need to prefetch this because every layout hits it when deciding whether
// to show the silo/system picker. It's also fetched by the SystemLayout
// loader to figure out whether to 404, but RQ dedupes the request.
- apiQueryClient.prefetchQuery('systemPolicyView', {}),
+ apiQueryClient.prefetchQuery('systemPolicyViewV1', {}),
])
return null
}
diff --git a/app/pages/OrgAccessPage.tsx b/app/pages/OrgAccessPage.tsx
index ba864d643..7adb9f6b2 100644
--- a/app/pages/OrgAccessPage.tsx
+++ b/app/pages/OrgAccessPage.tsx
@@ -27,7 +27,7 @@ import { groupBy, isTruthy } from '@oxide/util'
import { AccessNameCell } from 'app/components/AccessNameCell'
import { RoleBadgeCell } from 'app/components/RoleBadgeCell'
import { OrgAccessAddUserSideModal, OrgAccessEditUserSideModal } from 'app/forms/org-access'
-import { requireOrgParams, useRequiredParams } from 'app/hooks'
+import { getOrgSelector, useOrgSelector } from 'app/hooks'
const EmptyState = ({ onClick }: { onClick: () => void }) => (
@@ -43,9 +43,9 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => (
OrgAccessPage.loader = async ({ params }: LoaderFunctionArgs) => {
await Promise.all([
- apiQueryClient.prefetchQuery('policyView', {}),
- apiQueryClient.prefetchQuery('organizationPolicyView', {
- path: requireOrgParams(params),
+ apiQueryClient.prefetchQuery('policyViewV1', {}),
+ apiQueryClient.prefetchQuery('organizationPolicyViewV1', {
+ path: getOrgSelector(params),
}),
// used to resolve user names
apiQueryClient.prefetchQuery('userList', {}),
@@ -69,12 +69,14 @@ const colHelper = createColumnHelper()
export function OrgAccessPage() {
const [addModalOpen, setAddModalOpen] = useState(false)
const [editingUserRow, setEditingUserRow] = useState(null)
- const orgParams = useRequiredParams('orgName')
+ const { organization } = useOrgSelector()
- const { data: siloPolicy } = useApiQuery('policyView', {})
+ const { data: siloPolicy } = useApiQuery('policyViewV1', {})
const siloRows = useUserRows(siloPolicy?.roleAssignments, 'silo')
- const { data: orgPolicy } = useApiQuery('organizationPolicyView', { path: orgParams })
+ const { data: orgPolicy } = useApiQuery('organizationPolicyViewV1', {
+ path: { organization },
+ })
const orgRows = useUserRows(orgPolicy?.roleAssignments, 'org')
const rows = useMemo(() => {
@@ -103,9 +105,9 @@ export function OrgAccessPage() {
}, [siloRows, orgRows])
const queryClient = useApiQueryClient()
- const updatePolicy = useApiMutation('organizationPolicyUpdate', {
+ const updatePolicy = useApiMutation('organizationPolicyUpdateV1', {
onSuccess: () =>
- queryClient.invalidateQueries('organizationPolicyView', { path: orgParams }),
+ queryClient.invalidateQueries('organizationPolicyViewV1', { path: { organization } }),
// TODO: handle 403
})
@@ -137,7 +139,7 @@ export function OrgAccessPage() {
onActivate() {
// TODO: confirm delete
updatePolicy.mutate({
- path: orgParams,
+ path: { organization },
// we know policy is there, otherwise there's no row to display
body: deleteRole(row.id, orgPolicy!),
})
@@ -146,7 +148,7 @@ export function OrgAccessPage() {
},
]),
],
- [orgPolicy, orgParams, updatePolicy]
+ [orgPolicy, organization, updatePolicy]
)
const tableInstance = useReactTable({ columns, data: rows })
diff --git a/app/pages/OrgsPage.tsx b/app/pages/OrgsPage.tsx
index f78a48635..e1eb99e41 100644
--- a/app/pages/OrgsPage.tsx
+++ b/app/pages/OrgsPage.tsx
@@ -31,23 +31,23 @@ const EmptyState = () => (
)
OrgsPage.loader = async () => {
- await apiQueryClient.prefetchQuery('organizationList', { query: { limit: 10 } })
+ await apiQueryClient.prefetchQuery('organizationListV1', { query: { limit: 10 } })
return null
}
export default function OrgsPage() {
const navigate = useNavigate()
- const { Table, Column } = useQueryTable('organizationList', {})
+ const { Table, Column } = useQueryTable('organizationListV1', {})
const queryClient = useApiQueryClient()
- const { data: orgs } = useApiQuery('organizationList', {
+ const { data: orgs } = useApiQuery('organizationListV1', {
query: { limit: 10 }, // to have same params as QueryTable
})
- const deleteOrg = useApiMutation('organizationDelete', {
+ const deleteOrg = useApiMutation('organizationDeleteV1', {
onSuccess() {
- queryClient.invalidateQueries('organizationList', {})
+ queryClient.invalidateQueries('organizationListV1', {})
},
})
@@ -55,15 +55,18 @@ export default function OrgsPage() {
{
label: 'Edit',
onActivate() {
- const path = { orgName: org.name }
- apiQueryClient.setQueryData('organizationView', { path }, org)
- navigate(pb.orgEdit({ orgName: org.name }))
+ apiQueryClient.setQueryData(
+ 'organizationViewV1',
+ { path: { organization: org.name } },
+ org
+ )
+ navigate(pb.orgEdit({ organization: org.name }))
},
},
{
label: 'Delete',
onActivate: () => {
- deleteOrg.mutate({ path: { orgName: org.name } })
+ deleteOrg.mutate({ path: { organization: org.name } })
},
},
]
@@ -74,7 +77,7 @@ export default function OrgsPage() {
{ value: 'New organization', onSelect: () => navigate(pb.orgNew()) },
...(orgs?.items || []).map((o) => ({
value: o.name,
- onSelect: () => navigate(pb.org({ orgName: o.name })),
+ onSelect: () => navigate(pb.org({ organization: o.name })),
navGroup: 'Go to organization',
})),
],
@@ -93,7 +96,10 @@ export default function OrgsPage() {
} makeActions={makeActions}>
- pb.projects({ orgName }))} />
+ pb.projects({ organization }))}
+ />
diff --git a/app/pages/ProjectsPage.tsx b/app/pages/ProjectsPage.tsx
index fbcc46024..820764a4d 100644
--- a/app/pages/ProjectsPage.tsx
+++ b/app/pages/ProjectsPage.tsx
@@ -4,8 +4,7 @@ import { Outlet } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import type { Project } from '@oxide/api'
-import { apiQueryClient } from '@oxide/api'
-import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api'
+import { apiQueryClient, useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api'
import type { MenuAction } from '@oxide/table'
import { DateCell, linkCell, useQueryTable } from '@oxide/table'
import {
@@ -19,7 +18,7 @@ import {
import { pb } from 'app/util/path-builder'
-import { requireOrgParams, useOrgParams, useQuickActions } from '../hooks'
+import { getOrgSelector, useOrgSelector, useQuickActions } from '../hooks'
const EmptyState = () => (
(
title="No projects"
body="You need to create a project to be able to see it here"
buttonText="New project"
- buttonTo={pb.projectNew(useOrgParams())}
+ buttonTo={pb.projectNew(useOrgSelector())}
/>
)
ProjectsPage.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('projectList', {
- path: requireOrgParams(params),
- query: { limit: 10 },
+ const { organization } = getOrgSelector(params)
+ await apiQueryClient.prefetchQuery('projectListV1', {
+ query: { organization, limit: 10 },
})
return null
}
@@ -43,19 +42,18 @@ export default function ProjectsPage() {
const navigate = useNavigate()
const queryClient = useApiQueryClient()
- const { orgName } = useOrgParams()
- const { Table, Column } = useQueryTable('projectList', {
- path: { orgName },
- })
+ const { organization } = useOrgSelector()
+ const { Table, Column } = useQueryTable('projectListV1', { query: { organization } })
- const { data: projects } = useApiQuery('projectList', {
- path: { orgName },
- query: { limit: 10 }, // to have same params as QueryTable
+ const { data: projects } = useApiQuery('projectListV1', {
+ query: { ...{ organization }, limit: 10 }, // limit to match QueryTable
})
- const deleteProject = useApiMutation('projectDelete', {
+ const deleteProject = useApiMutation('projectDeleteV1', {
onSuccess() {
- queryClient.invalidateQueries('projectList', { path: { orgName } })
+ // TODO: figure out if this is invalidating as expected, can we leave out the query
+ // altogether, etc. Look at whether limit param matters.
+ queryClient.invalidateQueries('projectListV1', { query: { organization } })
},
})
@@ -63,17 +61,26 @@ export default function ProjectsPage() {
{
label: 'Edit',
onActivate: () => {
- const path = { orgName, projectName: project.name }
// the edit view has its own loader, but we can make the modal open
// instantaneously by preloading the fetch result
- apiQueryClient.setQueryData('projectView', { path }, project)
- navigate(pb.projectEdit(path))
+ apiQueryClient.setQueryData(
+ 'projectViewV1',
+ {
+ path: { project: project.name },
+ query: { organization },
+ },
+ project
+ )
+ navigate(pb.projectEdit({ organization, project: project.name }))
},
},
{
label: 'Delete',
onActivate: () => {
- deleteProject.mutate({ path: { orgName, projectName: project.name } })
+ deleteProject.mutate({
+ path: { project: project.name },
+ query: { organization },
+ })
},
},
]
@@ -81,14 +88,17 @@ export default function ProjectsPage() {
useQuickActions(
useMemo(
() => [
- { value: 'New project', onSelect: () => navigate(pb.projectNew({ orgName })) },
+ {
+ value: 'New project',
+ onSelect: () => navigate(pb.projectNew({ organization })),
+ },
...(projects?.items || []).map((p) => ({
value: p.name,
- onSelect: () => navigate(pb.instances({ orgName, projectName: p.name })),
+ onSelect: () => navigate(pb.instances({ organization, project: p.name })),
navGroup: 'Go to project',
})),
],
- [orgName, navigate, projects]
+ [organization, navigate, projects]
)
)
@@ -98,14 +108,14 @@ export default function ProjectsPage() {
}>Projects
-
+
New Project
} makeActions={makeActions}>
pb.instances({ orgName, projectName }))}
+ cell={linkCell((project) => pb.instances({ organization, project }))}
/>
diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx
index 255d16c4d..fe42d7941 100644
--- a/app/pages/SiloAccessPage.tsx
+++ b/app/pages/SiloAccessPage.tsx
@@ -44,7 +44,7 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => (
SiloAccessPage.loader = async () => {
await Promise.all([
- apiQueryClient.prefetchQuery('policyView', {}),
+ apiQueryClient.prefetchQuery('policyViewV1', {}),
// used to resolve user names
apiQueryClient.prefetchQuery('userList', {}),
apiQueryClient.prefetchQuery('groupList', {}),
@@ -66,7 +66,7 @@ export function SiloAccessPage() {
const [addModalOpen, setAddModalOpen] = useState(false)
const [editingUserRow, setEditingUserRow] = useState(null)
- const { data: siloPolicy } = useApiQuery('policyView', {})
+ const { data: siloPolicy } = useApiQuery('policyViewV1', {})
const siloRows = useUserRows(siloPolicy?.roleAssignments, 'silo')
const rows = useMemo(() => {
@@ -93,8 +93,8 @@ export function SiloAccessPage() {
}, [siloRows])
const queryClient = useApiQueryClient()
- const updatePolicy = useApiMutation('policyUpdate', {
- onSuccess: () => queryClient.invalidateQueries('policyView', {}),
+ const updatePolicy = useApiMutation('policyUpdateV1', {
+ onSuccess: () => queryClient.invalidateQueries('policyViewV1', {}),
// TODO: handle 403
})
diff --git a/app/pages/SiloUtilizationPage.tsx b/app/pages/SiloUtilizationPage.tsx
index c804791f4..7e07fdda7 100644
--- a/app/pages/SiloUtilizationPage.tsx
+++ b/app/pages/SiloUtilizationPage.tsx
@@ -14,7 +14,7 @@ const ALL_PROJECTS = '|ALL_PROJECTS|'
const toListboxItem = (x: { name: string; id: string }) => ({ label: x.name, value: x.id })
SiloUtilizationPage.loader = async () => {
- await apiQueryClient.prefetchQuery('organizationList', {})
+ await apiQueryClient.prefetchQuery('organizationListV1', {})
return null
}
@@ -24,13 +24,13 @@ export function SiloUtilizationPage() {
const [orgId, setOrgId] = useState(siloId)
const [projectId, setProjectId] = useState(null)
- const { data: orgs } = useApiQuery('organizationList', {})
+ const { data: orgs } = useApiQuery('organizationListV1', {})
const orgName = orgs?.items.find((o) => orgId && o.id === orgId)?.name
const { data: projects } = useApiQuery(
- 'projectList',
- { path: { orgName: orgName! } }, // only enabled if it's there
+ 'projectListV1',
+ { query: { organization: orgName! } }, // only enabled if it's there
{ enabled: !!orgName }
)
diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx
index 715bf81fc..19076952d 100644
--- a/app/pages/project/access/ProjectAccessPage.tsx
+++ b/app/pages/project/access/ProjectAccessPage.tsx
@@ -3,10 +3,10 @@ import { useMemo, useState } from 'react'
import type { LoaderFunctionArgs } from 'react-router-dom'
import type { IdentityType, RoleKey } from '@oxide/api'
-import { deleteRole } from '@oxide/api'
import {
apiQueryClient,
byGroupThenName,
+ deleteRole,
getEffectiveRole,
useApiMutation,
useApiQuery,
@@ -23,7 +23,7 @@ import {
TableActions,
TableEmptyBox,
} from '@oxide/ui'
-import { groupBy, isTruthy } from '@oxide/util'
+import { groupBy, isTruthy, toPathQuery } from '@oxide/util'
import { AccessNameCell } from 'app/components/AccessNameCell'
import { RoleBadgeCell } from 'app/components/RoleBadgeCell'
@@ -31,7 +31,7 @@ import {
ProjectAccessAddUserSideModal,
ProjectAccessEditUserSideModal,
} from 'app/forms/project-access'
-import { requireProjectParams, useRequiredParams } from 'app/hooks'
+import { getProjectSelector, useProjectSelector } from 'app/hooks'
const EmptyState = ({ onClick }: { onClick: () => void }) => (
@@ -46,11 +46,14 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => (
)
ProjectAccessPage.loader = async ({ params }: LoaderFunctionArgs) => {
- const { orgName, projectName } = requireProjectParams(params)
+ const { organization, project } = getProjectSelector(params)
await Promise.all([
- apiQueryClient.prefetchQuery('policyView', {}),
- apiQueryClient.prefetchQuery('organizationPolicyView', { path: { orgName } }),
- apiQueryClient.prefetchQuery('projectPolicyView', { path: { orgName, projectName } }),
+ apiQueryClient.prefetchQuery('policyViewV1', {}),
+ apiQueryClient.prefetchQuery('organizationPolicyViewV1', { path: { organization } }),
+ apiQueryClient.prefetchQuery('projectPolicyViewV1', {
+ path: { project },
+ query: { organization },
+ }),
// used to resolve user names
apiQueryClient.prefetchQuery('userList', {}),
apiQueryClient.prefetchQuery('groupList', {}),
@@ -73,16 +76,19 @@ const colHelper = createColumnHelper()
export function ProjectAccessPage() {
const [addModalOpen, setAddModalOpen] = useState(false)
const [editingUserRow, setEditingUserRow] = useState(null)
- const projectParams = useRequiredParams('orgName', 'projectName')
- const { orgName } = projectParams
+ const projectSelector = useProjectSelector()
+ const projectPathQuery = toPathQuery('project', projectSelector)
+ const { organization } = projectSelector
- const { data: siloPolicy } = useApiQuery('policyView', {})
+ const { data: siloPolicy } = useApiQuery('policyViewV1', {})
const siloRows = useUserRows(siloPolicy?.roleAssignments, 'silo')
- const { data: orgPolicy } = useApiQuery('organizationPolicyView', { path: { orgName } })
+ const { data: orgPolicy } = useApiQuery('organizationPolicyViewV1', {
+ path: { organization },
+ })
const orgRows = useUserRows(orgPolicy?.roleAssignments, 'org')
- const { data: projectPolicy } = useApiQuery('projectPolicyView', { path: projectParams })
+ const { data: projectPolicy } = useApiQuery('projectPolicyViewV1', projectPathQuery)
const projectRows = useUserRows(projectPolicy?.roleAssignments, 'project')
const rows = useMemo(() => {
@@ -115,9 +121,8 @@ export function ProjectAccessPage() {
}, [siloRows, orgRows, projectRows])
const queryClient = useApiQueryClient()
- const updatePolicy = useApiMutation('projectPolicyUpdate', {
- onSuccess: () =>
- queryClient.invalidateQueries('projectPolicyView', { path: projectParams }),
+ const updatePolicy = useApiMutation('projectPolicyUpdateV1', {
+ onSuccess: () => queryClient.invalidateQueries('projectPolicyViewV1', projectPathQuery),
// TODO: handle 403
})
@@ -154,7 +159,7 @@ export function ProjectAccessPage() {
onActivate() {
// TODO: confirm delete
updatePolicy.mutate({
- path: projectParams,
+ ...projectPathQuery,
// we know policy is there, otherwise there's no row to display
body: deleteRole(row.id, projectPolicy!),
})
@@ -163,7 +168,7 @@ export function ProjectAccessPage() {
},
]),
],
- [projectPolicy, projectParams, updatePolicy]
+ [projectPolicy, projectPathQuery, updatePolicy]
)
const tableInstance = useReactTable({ columns, data: rows })
diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx
index de13541d0..49d885cb4 100644
--- a/app/pages/project/disks/DisksPage.tsx
+++ b/app/pages/project/disks/DisksPage.tsx
@@ -3,14 +3,14 @@ import { Outlet } from 'react-router-dom'
import { Link } from 'react-router-dom'
import type { Disk } from '@oxide/api'
-import { genName } from '@oxide/api'
-import { apiQueryClient } from '@oxide/api'
-import { useApiMutation, useApiQueryClient } from '@oxide/api'
-import { useApiQuery } from '@oxide/api'
-import type { MenuAction } from '@oxide/table'
-import { DateCell } from '@oxide/table'
-import { SizeCell } from '@oxide/table'
-import { useQueryTable } from '@oxide/table'
+import {
+ apiQueryClient,
+ genName,
+ useApiMutation,
+ useApiQuery,
+ useApiQueryClient,
+} from '@oxide/api'
+import { DateCell, type MenuAction, SizeCell, useQueryTable } from '@oxide/table'
import {
EmptyMessage,
PageHeader,
@@ -22,28 +22,24 @@ import {
} from '@oxide/ui'
import { DiskStatusBadge } from 'app/components/StatusBadge'
-import {
- requireProjectParams,
- useProjectParams,
- useRequiredParams,
- useToast,
-} from 'app/hooks'
+import { getProjectSelector, useProjectSelector, useToast } from 'app/hooks'
import { pb } from 'app/util/path-builder'
function AttachedInstance({
- orgName,
- projectName,
instanceId,
+ ...projectSelector
}: {
- orgName: string
- projectName: string
+ organization: string
+ project: string
instanceId: string
}) {
- const { data: instance } = useApiQuery('instanceViewById', { path: { id: instanceId } })
+ const { data: instance } = useApiQuery('instanceViewV1', {
+ path: { instance: instanceId },
+ })
return instance ? (
{instance.name}
@@ -56,33 +52,32 @@ const EmptyState = () => (
title="No disks"
body="You need to create a disk to be able to see it here"
buttonText="New disk"
- buttonTo={pb.diskNew(useProjectParams())}
+ buttonTo={pb.diskNew(useProjectSelector())}
/>
)
DisksPage.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('diskList', {
- path: requireProjectParams(params),
- query: { limit: 10 },
+ await apiQueryClient.prefetchQuery('diskListV1', {
+ query: { ...getProjectSelector(params), limit: 10 },
})
return null
}
export function DisksPage() {
const queryClient = useApiQueryClient()
- const { orgName, projectName } = useRequiredParams('orgName', 'projectName')
- const { Table, Column } = useQueryTable('diskList', { path: { orgName, projectName } })
+ const projectSelector = useProjectSelector()
+ const { Table, Column } = useQueryTable('diskListV1', { query: projectSelector })
const addToast = useToast()
- const deleteDisk = useApiMutation('diskDelete', {
+ const deleteDisk = useApiMutation('diskDeleteV1', {
onSuccess() {
- queryClient.invalidateQueries('diskList', { path: { orgName, projectName } })
+ queryClient.invalidateQueries('diskListV1', { query: projectSelector })
},
})
- const createSnapshot = useApiMutation('snapshotCreate', {
+ const createSnapshot = useApiMutation('snapshotCreateV1', {
onSuccess() {
- queryClient.invalidateQueries('snapshotList', { path: { orgName, projectName } })
+ queryClient.invalidateQueries('snapshotListV1', { query: projectSelector })
addToast({
icon: ,
title: 'Success!',
@@ -96,7 +91,7 @@ export function DisksPage() {
label: 'Snapshot',
onActivate() {
createSnapshot.mutate({
- path: { orgName, projectName },
+ query: projectSelector,
body: {
name: genName(disk.name),
disk: disk.name,
@@ -111,7 +106,7 @@ export function DisksPage() {
{
label: 'Delete',
onActivate: () => {
- deleteDisk.mutate({ path: { orgName, projectName, diskName: disk.name } })
+ deleteDisk.mutate({ path: { disk: disk.name }, query: projectSelector })
},
disabled:
!['detached', 'creating', 'faulted'].includes(disk.state.state) &&
@@ -127,10 +122,7 @@ export function DisksPage() {
}>Disks
-
+
New Disk
@@ -146,13 +138,7 @@ export function DisksPage() {
'instance' in disk.state ? disk.state.instance : null
}
cell={({ value }: { value: string | undefined }) =>
- value ? (
-
- ) : null
+ value ? : null
}
/>
diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx
index 80cd7c95c..dd16ac47f 100644
--- a/app/pages/project/instances/InstancesPage.tsx
+++ b/app/pages/project/instances/InstancesPage.tsx
@@ -21,7 +21,7 @@ import {
buttonStyle,
} from '@oxide/ui'
-import { requireProjectParams, useProjectParams, useQuickActions } from 'app/hooks'
+import { getProjectSelector, useProjectSelector, useQuickActions } from 'app/hooks'
import { pb } from 'app/util/path-builder'
import { useMakeInstanceActions } from './actions'
@@ -32,54 +32,54 @@ const EmptyState = () => (
title="No instances"
body="You need to create an instance to be able to see it here"
buttonText="New instance"
- buttonTo={pb.instanceNew(useProjectParams())}
+ buttonTo={pb.instanceNew(useProjectSelector())}
/>
)
InstancesPage.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('instanceList', {
- path: requireProjectParams(params),
- query: { limit: 10 },
+ await apiQueryClient.prefetchQuery('instanceListV1', {
+ query: { ...getProjectSelector(params), limit: 10 },
})
return null
}
export function InstancesPage() {
- const projectParams = useProjectParams()
- const { orgName, projectName } = projectParams
+ const projectSelector = useProjectSelector()
const queryClient = useApiQueryClient()
const refetchInstances = () =>
- queryClient.invalidateQueries('instanceList', { path: projectParams })
+ queryClient.invalidateQueries('instanceListV1', { query: projectSelector })
- const makeActions = useMakeInstanceActions(projectParams, {
+ const makeActions = useMakeInstanceActions(projectSelector, {
onSuccess: refetchInstances,
})
- const { data: instances } = useApiQuery('instanceList', {
- path: projectParams,
- query: { limit: 10 }, // to have same params as QueryTable
+ const { data: instances } = useApiQuery('instanceListV1', {
+ query: { ...projectSelector, limit: 10 }, // to have same params as QueryTable
})
const navigate = useNavigate()
useQuickActions(
useMemo(
() => [
- { value: 'New instance', onSelect: () => navigate(pb.instanceNew(projectParams)) },
+ {
+ value: 'New instance',
+ onSelect: () => navigate(pb.instanceNew(projectSelector)),
+ },
...(instances?.items || []).map((i) => ({
value: i.name,
onSelect: () =>
- navigate(pb.instancePage({ ...projectParams, instanceName: i.name })),
+ navigate(pb.instancePage({ ...projectSelector, instance: i.name })),
navGroup: 'Go to instance',
})),
],
- [projectParams, instances, navigate]
+ [projectSelector, instances, navigate]
)
)
const { Table, Column } = useQueryTable(
- 'instanceList',
- { path: projectParams },
+ 'instanceListV1',
+ { query: projectSelector },
{ keepPreviousData: true }
)
@@ -94,19 +94,14 @@ export function InstancesPage() {
-
+
New Instance
}>
- pb.instancePage({ orgName, projectName, instanceName })
- )}
+ cell={linkCell((instance) => pb.instancePage({ ...projectSelector, instance }))}
/>
=> {
const navigate = useNavigate()
@@ -36,14 +36,15 @@ export const useMakeInstanceActions = (
// if you also pass onSuccess to mutate(), this one is not overridden — this
// one runs first, then the one passed to mutate()
const opts = { onSuccess: options.onSuccess }
- const startInstance = useApiMutation('instanceStart', opts)
- const stopInstance = useApiMutation('instanceStop', opts)
- const rebootInstance = useApiMutation('instanceReboot', opts)
- const deleteInstance = useApiMutation('instanceDelete', opts)
+ const startInstance = useApiMutation('instanceStartV1', opts)
+ const stopInstance = useApiMutation('instanceStopV1', opts)
+ const rebootInstance = useApiMutation('instanceRebootV1', opts)
+ const deleteInstance = useApiMutation('instanceDeleteV1', opts)
return useCallback((instance) => {
- const { name: instanceName } = instance
- const instanceParams = { path: { ...projectParams, instanceName } }
+ const instanceName = instance.name
+ const instanceSelector = { ...projectSelector, instance: instanceName }
+ const instanceParams = toPathQuery('instance', instanceSelector)
return [
{
label: 'Start',
@@ -75,7 +76,7 @@ export const useMakeInstanceActions = (
{
label: 'View serial console',
onActivate() {
- navigate(pb.serialConsole(instanceParams.path))
+ navigate(pb.serialConsole(instanceSelector))
},
},
{
diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx
index dbdd78bc8..95ab3c0c0 100644
--- a/app/pages/project/instances/instance/InstancePage.tsx
+++ b/app/pages/project/instances/instance/InstancePage.tsx
@@ -5,38 +5,43 @@ import { useNavigate } from 'react-router-dom'
import { apiQueryClient, useApiQuery, useApiQueryClient } from '@oxide/api'
import { Instances24Icon, PageHeader, PageTitle, PropertiesTable } from '@oxide/ui'
-import { pick } from '@oxide/util'
+import { toPathQuery } from '@oxide/util'
import { MoreActionsMenu } from 'app/components/MoreActionsMenu'
import { RouteTabs, Tab } from 'app/components/RouteTabs'
import { InstanceStatusBadge } from 'app/components/StatusBadge'
-import { requireInstanceParams, useQuickActions, useRequiredParams } from 'app/hooks'
+import { getInstanceSelector, useInstanceSelector, useQuickActions } from 'app/hooks'
import { pb } from 'app/util/path-builder'
import { useMakeInstanceActions } from '../actions'
InstancePage.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('instanceView', {
- path: requireInstanceParams(params),
- })
+ await apiQueryClient.prefetchQuery(
+ 'instanceViewV1',
+ toPathQuery('instance', getInstanceSelector(params))
+ )
return null
}
export function InstancePage() {
- const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName')
+ const instanceSelector = useInstanceSelector()
+ const { project, organization } = instanceSelector
+ const instancePathQuery = toPathQuery('instance', instanceSelector)
const navigate = useNavigate()
const queryClient = useApiQueryClient()
- const projectParams = pick(instanceParams, 'projectName', 'orgName')
- const makeActions = useMakeInstanceActions(projectParams, {
- onSuccess: () => {
- queryClient.invalidateQueries('instanceView', { path: instanceParams })
- },
- // go to project instances list since there's no more instance
- onDelete: () => navigate(pb.instances(projectParams)),
- })
+ const makeActions = useMakeInstanceActions(
+ { project, organization },
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries('instanceViewV1', instancePathQuery)
+ },
+ // go to project instances list since there's no more instance
+ onDelete: () => navigate(pb.instances(instanceSelector)),
+ }
+ )
- const { data: instance } = useApiQuery('instanceView', { path: instanceParams })
+ const { data: instance } = useApiQuery('instanceViewV1', instancePathQuery)
const actions = useMemo(
() => (instance ? makeActions(instance) : []),
[instance, makeActions]
@@ -85,10 +90,10 @@ export function InstancePage() {
- Storage
- Metrics
- Network Interfaces
- Serial Console
+ Storage
+ Metrics
+ Network Interfaces
+ Serial Console
>
)
diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx
index e92fb4859..c363d1c41 100644
--- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx
+++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx
@@ -1,16 +1,15 @@
import { getLocalTimeZone } from '@internationalized/date'
-import { Suspense, useMemo, useState } from 'react'
-import React from 'react'
+import React, { Suspense, useMemo, useState } from 'react'
import type { LoaderFunctionArgs } from 'react-router-dom'
import invariant from 'tiny-invariant'
import type { Cumulativeint64, DiskMetricName } from '@oxide/api'
-import { apiQueryClient } from '@oxide/api'
-import { useApiQuery } from '@oxide/api'
+import { apiQueryClient, useApiQuery } from '@oxide/api'
import { Listbox, Spinner } from '@oxide/ui'
+import { toPathQuery } from '@oxide/util'
import { useDateTimeRangePicker } from 'app/components/form'
-import { requireInstanceParams, useRequiredParams } from 'app/hooks'
+import { getInstanceSelector, useInstanceSelector } from 'app/hooks'
const TimeSeriesChart = React.lazy(() => import('app/components/TimeSeriesChart'))
@@ -76,29 +75,32 @@ function DiskMetric({
// date range, I'm inclined to punt.
MetricsTab.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('instanceDiskList', {
- path: requireInstanceParams(params),
- })
+ await apiQueryClient.prefetchQuery(
+ 'instanceDiskListV1',
+ toPathQuery('instance', getInstanceSelector(params))
+ )
return null
}
export function MetricsTab() {
- const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName')
- const { data } = useApiQuery('instanceDiskList', { path: instanceParams })
+ const instanceSelector = useInstanceSelector()
+ const { organization, project } = instanceSelector
+ const { data } = useApiQuery(
+ 'instanceDiskListV1',
+ toPathQuery('instance', instanceSelector)
+ )
const disks = useMemo(() => data?.items || [], [data])
// because of prefetch in the loader and because an instance should always
// have a disk, we should never see an empty list here
invariant(disks.length > 0, 'Instance disks list should never be empty')
- const { orgName, projectName } = useRequiredParams('orgName', 'projectName')
-
const { startTime, endTime, dateTimeRangePicker } = useDateTimeRangePicker('lastDay')
const [diskName, setDiskName] = useState(disks[0].name)
const diskItems = disks.map(({ name }) => ({ label: name, value: name }))
- const diskParams = { orgName, projectName, diskName }
+ const diskParams = { orgName: organization, projectName: project, diskName }
const commonProps = {
startTime: startTime.toDate(getLocalTimeZone()),
endTime: endTime.toDate(getLocalTimeZone()),
diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx
index 73ac49a41..5a630c189 100644
--- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx
+++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx
@@ -3,8 +3,7 @@ import type { LoaderFunctionArgs } from 'react-router-dom'
import { Link } from 'react-router-dom'
import type { NetworkInterface } from '@oxide/api'
-import { apiQueryClient } from '@oxide/api'
-import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api'
+import { apiQueryClient, useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api'
import type { MenuAction } from '@oxide/table'
import { useQueryTable } from '@oxide/table'
import {
@@ -16,20 +15,27 @@ import {
OpenLink12Icon,
Success12Icon,
} from '@oxide/ui'
+import { toPathQuery } from '@oxide/util'
import CreateNetworkInterfaceForm from 'app/forms/network-interface-create'
import EditNetworkInterfaceForm from 'app/forms/network-interface-edit'
-import { requireInstanceParams, useRequiredParams, useToast } from 'app/hooks'
+import {
+ getInstanceSelector,
+ useInstanceSelector,
+ useProjectSelector,
+ useRequiredParams,
+ useToast,
+} from 'app/hooks'
import { pb } from 'app/util/path-builder'
const VpcNameFromId = ({ value }: { value: string }) => {
- const { orgName, projectName } = useRequiredParams('orgName', 'projectName')
- const { data: vpc } = useApiQuery('vpcViewById', { path: { id: value } })
+ const projectSelector = useProjectSelector()
+ const { data: vpc } = useApiQuery('vpcViewV1', { path: { vpc: value } })
if (!vpc) return null
return (
{vpc.name}
@@ -38,7 +44,7 @@ const VpcNameFromId = ({ value }: { value: string }) => {
const SubnetNameFromId = ({ value }: { value: string }) => (
- {useApiQuery('vpcSubnetViewById', { path: { id: value } }).data?.name}
+ {useApiQuery('vpcSubnetViewV1', { path: { subnet: value } }).data?.name}
)
@@ -50,30 +56,33 @@ function ExternalIpsFromInstanceName({ value: primary }: { value: boolean }) {
}
NetworkingTab.loader = async ({ params }: LoaderFunctionArgs) => {
- const path = requireInstanceParams(params)
+ const instanceSelector = getInstanceSelector(params)
await Promise.all([
- await apiQueryClient.prefetchQuery('instanceNetworkInterfaceList', {
- path,
- query: { limit: 10 },
+ apiQueryClient.prefetchQuery('instanceNetworkInterfaceListV1', {
+ query: { ...instanceSelector, limit: 10 },
}),
// 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.
- apiQueryClient.prefetchQuery('instanceView', { path }),
+ apiQueryClient.prefetchQuery(
+ 'instanceViewV1',
+ toPathQuery('instance', instanceSelector)
+ ),
])
return null
}
export function NetworkingTab() {
- const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName')
+ const instanceSelector = useInstanceSelector()
+
const queryClient = useApiQueryClient()
const addToast = useToast()
const [createModalOpen, setCreateModalOpen] = useState(false)
const [editing, setEditing] = useState(null)
- const getQuery = ['instanceNetworkInterfaceList', { path: instanceParams }] as const
+ const getQuery = ['instanceNetworkInterfaceListV1', { query: instanceSelector }] as const
- const deleteNic = useApiMutation('instanceNetworkInterfaceDelete', {
+ const deleteNic = useApiMutation('instanceNetworkInterfaceDeleteV1', {
onSuccess() {
queryClient.invalidateQueries(...getQuery)
addToast({
@@ -83,21 +92,23 @@ export function NetworkingTab() {
},
})
- const editNic = useApiMutation('instanceNetworkInterfaceUpdate', {
+ const editNic = useApiMutation('instanceNetworkInterfaceUpdateV1', {
onSuccess() {
queryClient.invalidateQueries(...getQuery)
},
})
const instanceStopped =
- useApiQuery('instanceView', { path: instanceParams }).data?.runState === 'stopped'
+ useApiQuery('instanceViewV1', toPathQuery('instance', instanceSelector)).data
+ ?.runState === 'stopped'
const makeActions = (nic: NetworkInterface): MenuAction[] => [
{
label: 'Make primary',
onActivate() {
editNic.mutate({
- path: { ...instanceParams, interfaceName: nic.name },
+ path: { interface: nic.name },
+ query: instanceSelector,
body: { ...nic, primary: true },
})
},
@@ -118,7 +129,7 @@ export function NetworkingTab() {
{
label: 'Delete',
onActivate: () => {
- deleteNic.mutate({ path: { ...instanceParams, interfaceName: nic.name } })
+ deleteNic.mutate({ path: { interface: nic.name }, query: instanceSelector })
},
disabled:
!instanceStopped && 'The instance must be stopped to delete a network interface.',
diff --git a/app/pages/project/instances/instance/tabs/SerialConsoleTab.tsx b/app/pages/project/instances/instance/tabs/SerialConsoleTab.tsx
index 539a34cae..ac1b16aba 100644
--- a/app/pages/project/instances/instance/tabs/SerialConsoleTab.tsx
+++ b/app/pages/project/instances/instance/tabs/SerialConsoleTab.tsx
@@ -5,16 +5,20 @@ import { Button } from '@oxide/ui'
import { MiB } from '@oxide/util'
import { PageActions } from 'app/components/PageActions'
-import { useRequiredParams } from 'app/hooks'
+import { useInstanceSelector } from 'app/hooks'
const Terminal = lazy(() => import('app/components/Terminal'))
export function SerialConsoleTab() {
- const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName')
+ const { organization, project, instance } = useInstanceSelector()
const { data, refetch } = useApiQuery(
- 'instanceSerialConsole',
- { path: instanceParams, query: { maxBytes: 10 * MiB, fromStart: 0 } },
+ 'instanceSerialConsoleV1',
+ {
+ path: { instance },
+ // holding off on using toPathQuery for now because it doesn't like numbers
+ query: { organization, project, maxBytes: 10 * MiB, fromStart: 0 },
+ },
{ refetchOnWindowFocus: false }
)
diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx
index 8edb3bb16..961bbaf01 100644
--- a/app/pages/project/instances/instance/tabs/StorageTab.tsx
+++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx
@@ -1,9 +1,13 @@
import { useCallback, useMemo, useState } from 'react'
import type { LoaderFunctionArgs } from 'react-router-dom'
-import type { Disk } from '@oxide/api'
-import { apiQueryClient } from '@oxide/api'
-import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api'
+import {
+ type Disk,
+ apiQueryClient,
+ useApiMutation,
+ useApiQuery,
+ useApiQueryClient,
+} from '@oxide/api'
import type { MenuAction } from '@oxide/table'
import {
DateCell,
@@ -14,11 +18,12 @@ import {
useReactTable,
} from '@oxide/table'
import { Button, EmptyMessage, Error16Icon, OpenLink12Icon, TableEmptyBox } from '@oxide/ui'
+import { toPathQuery } from '@oxide/util'
import { DiskStatusBadge } from 'app/components/StatusBadge'
import AttachDiskSideModalForm from 'app/forms/disk-attach'
import { CreateDiskSideModalForm } from 'app/forms/disk-create'
-import { requireInstanceParams, useRequiredParams, useToast } from 'app/hooks'
+import { getInstanceSelector, useInstanceSelector, useToast } from 'app/hooks'
const OtherDisksEmpty = () => (
@@ -52,12 +57,12 @@ const staticCols = [
]
StorageTab.loader = async ({ params }: LoaderFunctionArgs) => {
- const path = requireInstanceParams(params)
+ const instancePathQuery = toPathQuery('instance', getInstanceSelector(params))
await Promise.all([
- apiQueryClient.prefetchQuery('instanceDiskList', { path }),
+ apiQueryClient.prefetchQuery('instanceDiskListV1', instancePathQuery),
// 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.
- apiQueryClient.prefetchQuery('instanceView', { path }),
+ apiQueryClient.prefetchQuery('instanceViewV1', instancePathQuery),
])
return null
}
@@ -68,14 +73,14 @@ export function StorageTab() {
const addToast = useToast()
const queryClient = useApiQueryClient()
- const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName')
+ const instancePathQuery = toPathQuery('instance', useInstanceSelector())
- const { data } = useApiQuery('instanceDiskList', { path: instanceParams })
+ const { data } = useApiQuery('instanceDiskListV1', instancePathQuery)
- const detachDisk = useApiMutation('instanceDiskDetach', {})
+ const detachDisk = useApiMutation('instanceDiskDetachV1', {})
const instanceStopped =
- useApiQuery('instanceView', { path: instanceParams }).data?.runState === 'stopped'
+ useApiQuery('instanceViewV1', instancePathQuery).data?.runState === 'stopped'
const makeActions = useCallback(
(disk: Disk): MenuAction[] => [
@@ -85,22 +90,23 @@ export function StorageTab() {
!instanceStopped && 'Instance must be stopped before disk can be detached',
onActivate() {
detachDisk.mutate(
- { body: { name: disk.name }, path: instanceParams },
+ { body: { disk: disk.name }, ...instancePathQuery },
{
onSuccess: () => {
- queryClient.invalidateQueries('instanceDiskList', { path: instanceParams })
+ queryClient.invalidateQueries('instanceDiskListV1', instancePathQuery)
},
}
)
},
},
],
- [detachDisk, instanceParams, instanceStopped, queryClient]
+ [detachDisk, instanceStopped, queryClient, instancePathQuery]
)
- const attachDisk = useApiMutation('instanceDiskAttach', {
+ const attachDisk = useApiMutation('instanceDiskAttachV1', {
onSuccess() {
- queryClient.invalidateQueries('instanceDiskList', { path: instanceParams })
+ console.log('disk atttach success')
+ queryClient.invalidateQueries('instanceDiskListV1', instancePathQuery)
},
onError(err) {
addToast({
@@ -182,7 +188,7 @@ export function StorageTab() {
onSuccess={({ name }) => {
// TODO: this should probably be done with `mutateAsync` and
// awaited, but it's a pain, so punt for now
- attachDisk.mutate({ path: instanceParams, body: { name } })
+ attachDisk.mutate({ ...instancePathQuery, body: { disk: name } })
}}
/>
)}
diff --git a/app/pages/project/networking/VpcPage/VpcPage.tsx b/app/pages/project/networking/VpcPage/VpcPage.tsx
index 4b4778712..34adef3f1 100644
--- a/app/pages/project/networking/VpcPage/VpcPage.tsx
+++ b/app/pages/project/networking/VpcPage/VpcPage.tsx
@@ -2,10 +2,10 @@ import type { LoaderFunctionArgs } from 'react-router-dom'
import { apiQueryClient, useApiQuery } from '@oxide/api'
import { Networking24Icon, PageHeader, PageTitle, PropertiesTable } from '@oxide/ui'
-import { formatDateTime } from '@oxide/util'
+import { formatDateTime, toPathQuery } from '@oxide/util'
import { Tab, Tabs } from 'app/components/Tabs'
-import { requireVpcParams, useVpcParams } from 'app/hooks'
+import { getVpcSelector, useVpcSelector } from 'app/hooks'
import { VpcFirewallRulesTab } from './tabs/VpcFirewallRulesTab'
import { VpcRoutersTab } from './tabs/VpcRoutersTab'
@@ -13,13 +13,16 @@ import { VpcSubnetsTab } from './tabs/VpcSubnetsTab'
import { VpcSystemRoutesTab } from './tabs/VpcSystemRoutesTab'
VpcPage.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('vpcView', { path: requireVpcParams(params) })
+ await apiQueryClient.prefetchQuery(
+ 'vpcViewV1',
+ toPathQuery('vpc', getVpcSelector(params))
+ )
return null
}
export function VpcPage() {
- const vpcParams = useVpcParams()
- const { data: vpc } = useApiQuery('vpcView', { path: vpcParams })
+ const vpcSelector = useVpcSelector()
+ const { data: vpc } = useApiQuery('vpcViewV1', toPathQuery('vpc', vpcSelector))
return (
<>
diff --git a/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx
index cf23158ee..76e17dddb 100644
--- a/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx
+++ b/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx
@@ -7,12 +7,12 @@ import { Button, EmptyMessage } from '@oxide/ui'
import { CreateVpcRouterForm } from 'app/forms/vpc-router-create'
import { EditVpcRouterForm } from 'app/forms/vpc-router-edit'
-import { useRequiredParams } from 'app/hooks'
+import { useVpcSelector } from 'app/hooks'
export const VpcRoutersTab = () => {
- const vpcParams = useRequiredParams('orgName', 'projectName', 'vpcName')
+ const vpcSelector = useVpcSelector()
- const { Table, Column } = useQueryTable('vpcRouterList', { path: vpcParams })
+ const { Table, Column } = useQueryTable('vpcRouterListV1', { query: vpcSelector })
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState(null)
diff --git a/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx
index fec0f34f0..b52ca1dea 100644
--- a/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx
+++ b/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx
@@ -7,12 +7,12 @@ import { Button, EmptyMessage } from '@oxide/ui'
import { CreateSubnetForm } from 'app/forms/subnet-create'
import { EditSubnetForm } from 'app/forms/subnet-edit'
-import { useRequiredParams } from 'app/hooks'
+import { useVpcSelector } from 'app/hooks'
export const VpcSubnetsTab = () => {
- const vpcParams = useRequiredParams('orgName', 'projectName', 'vpcName')
+ const vpcSelector = useVpcSelector()
- const { Table, Column } = useQueryTable('vpcSubnetList', { path: vpcParams })
+ const { Table, Column } = useQueryTable('vpcSubnetListV1', { query: vpcSelector })
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState(null)
diff --git a/app/pages/project/networking/VpcPage/tabs/VpcSystemRoutesTab.tsx b/app/pages/project/networking/VpcPage/tabs/VpcSystemRoutesTab.tsx
index c3b5bf65d..d4c024823 100644
--- a/app/pages/project/networking/VpcPage/tabs/VpcSystemRoutesTab.tsx
+++ b/app/pages/project/networking/VpcPage/tabs/VpcSystemRoutesTab.tsx
@@ -1,7 +1,7 @@
import { TypeValueCell, useQueryTable } from '@oxide/table'
import { EmptyMessage } from '@oxide/ui'
-import { useRequiredParams } from 'app/hooks'
+import { useVpcSelector } from 'app/hooks'
const EmptyState = () => (
(
)
export const VpcSystemRoutesTab = () => {
- const vpcParams = useRequiredParams('orgName', 'projectName', 'vpcName')
-
- const { Table, Column } = useQueryTable('vpcRouterRouteList', {
- path: { routerName: 'system', ...vpcParams },
+ const vpcSelector = useVpcSelector()
+ const { Table, Column } = useQueryTable('vpcRouterRouteListV1', {
+ query: { ...vpcSelector, router: 'system' },
})
return (
diff --git a/app/pages/project/networking/VpcsPage.tsx b/app/pages/project/networking/VpcsPage.tsx
index 14cc5ac9c..6529ee61f 100644
--- a/app/pages/project/networking/VpcsPage.tsx
+++ b/app/pages/project/networking/VpcsPage.tsx
@@ -16,12 +16,7 @@ import {
buttonStyle,
} from '@oxide/ui'
-import {
- requireProjectParams,
- useProjectParams,
- useQuickActions,
- useRequiredParams,
-} from 'app/hooks'
+import { getProjectSelector, useProjectSelector, useQuickActions } from 'app/hooks'
import { pb } from 'app/util/path-builder'
const EmptyState = () => (
@@ -30,32 +25,30 @@ const EmptyState = () => (
title="No VPCs"
body="You need to create a VPC to be able to see it here"
buttonText="New VPC"
- buttonTo={pb.vpcNew(useProjectParams())}
+ buttonTo={pb.vpcNew(useProjectSelector())}
/>
)
// just as in the vpcList call for the quick actions menu, include limit: 10 to make
// sure it matches the call in the QueryTable
VpcsPage.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('vpcList', {
- path: requireProjectParams(params),
- query: { limit: 10 },
+ await apiQueryClient.prefetchQuery('vpcListV1', {
+ query: { ...getProjectSelector(params), limit: 10 },
})
return null
}
export function VpcsPage() {
const queryClient = useApiQueryClient()
- const { orgName, projectName } = useRequiredParams('orgName', 'projectName')
- const { data: vpcs } = useApiQuery('vpcList', {
- path: { orgName, projectName },
- query: { limit: 10 }, // to have same params as QueryTable
+ const projectSelector = useProjectSelector()
+ const { data: vpcs } = useApiQuery('vpcListV1', {
+ query: { ...projectSelector, limit: 10 }, // to have same params as QueryTable
})
const navigate = useNavigate()
- const deleteVpc = useApiMutation('vpcDelete', {
+ const deleteVpc = useApiMutation('vpcDeleteV1', {
onSuccess() {
- queryClient.invalidateQueries('vpcList', { path: { orgName, projectName } })
+ queryClient.invalidateQueries('vpcListV1', { query: projectSelector })
},
})
@@ -63,13 +56,13 @@ export function VpcsPage() {
{
label: 'Edit',
onActivate() {
- navigate(pb.vpcEdit({ orgName, projectName, vpcName: vpc.name }), { state: vpc })
+ navigate(pb.vpcEdit({ ...projectSelector, vpc: vpc.name }), { state: vpc })
},
},
{
label: 'Delete',
onActivate() {
- deleteVpc.mutate({ path: { orgName, projectName, vpcName: vpc.name } })
+ deleteVpc.mutate({ path: { vpc: vpc.name }, query: projectSelector })
},
},
]
@@ -79,31 +72,28 @@ export function VpcsPage() {
() =>
(vpcs?.items || []).map((v) => ({
value: v.name,
- onSelect: () => navigate(pb.vpc({ orgName, projectName, vpcName: v.name })),
+ onSelect: () => navigate(pb.vpc({ ...projectSelector, vpc: v.name })),
navGroup: 'Go to VPC',
})),
- [orgName, projectName, vpcs, navigate]
+ [projectSelector, vpcs, navigate]
)
)
- const { Table, Column } = useQueryTable('vpcList', { path: { orgName, projectName } })
+ const { Table, Column } = useQueryTable('vpcListV1', { query: projectSelector })
return (
<>
}>VPCs
-
+
New Vpc
} makeActions={makeActions}>
pb.vpc({ orgName, projectName, vpcName }))}
+ cell={linkCell((vpc) => pb.vpc({ ...projectSelector, vpc }))}
/>
diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx
index e8b16a32b..380a2eb91 100644
--- a/app/pages/project/snapshots/SnapshotsPage.tsx
+++ b/app/pages/project/snapshots/SnapshotsPage.tsx
@@ -15,11 +15,11 @@ import {
} from '@oxide/ui'
import { SnapshotStatusBadge } from 'app/components/StatusBadge'
-import { requireProjectParams, useProjectParams, useRequiredParams } from 'app/hooks'
+import { getProjectSelector, useProjectSelector } from 'app/hooks'
import { pb } from 'app/util/path-builder'
const DiskNameFromId = ({ value }: { value: string }) => {
- const { data: disk } = useApiQuery('diskViewById', { path: { id: value } })
+ const { data: disk } = useApiQuery('diskViewV1', { path: { disk: value } })
if (!disk) return null
return <>{disk.name}>
}
@@ -30,26 +30,25 @@ const EmptyState = () => (
title="No snapshots"
body="You need to create a snapshot to be able to see it here"
buttonText="New snapshot"
- buttonTo={pb.snapshotNew(useProjectParams())}
+ buttonTo={pb.snapshotNew(useProjectSelector())}
/>
)
SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => {
- await apiQueryClient.prefetchQuery('snapshotList', {
- path: requireProjectParams(params),
- query: { limit: 10 },
+ await apiQueryClient.prefetchQuery('snapshotListV1', {
+ query: { ...getProjectSelector(params), limit: 10 },
})
return null
}
export function SnapshotsPage() {
const queryClient = useApiQueryClient()
- const projectParams = useRequiredParams('orgName', 'projectName')
- const { Table, Column } = useQueryTable('snapshotList', { path: projectParams })
+ const projectSelector = useProjectSelector()
+ const { Table, Column } = useQueryTable('snapshotListV1', { query: projectSelector })
- const deleteSnapshot = useApiMutation('snapshotDelete', {
+ const deleteSnapshot = useApiMutation('snapshotDeleteV1', {
onSuccess() {
- queryClient.invalidateQueries('snapshotList', { path: projectParams })
+ queryClient.invalidateQueries('snapshotListV1', { query: projectSelector })
},
})
@@ -57,7 +56,7 @@ export function SnapshotsPage() {
{
label: 'Delete',
onActivate() {
- deleteSnapshot.mutate({ path: { ...projectParams, snapshotName: snapshot.name } })
+ deleteSnapshot.mutate({ path: { snapshot: snapshot.name }, query: projectSelector })
},
},
]
@@ -68,7 +67,7 @@ export function SnapshotsPage() {
}>Snapshots
-
+
New Snapshot
diff --git a/app/pages/system/InventoryPage/DisksTab.tsx b/app/pages/system/InventoryPage/DisksTab.tsx
index 6f896d5e1..96f96b21c 100644
--- a/app/pages/system/InventoryPage/DisksTab.tsx
+++ b/app/pages/system/InventoryPage/DisksTab.tsx
@@ -13,14 +13,12 @@ const EmptyState = () => {
}
DisksTab.loader = async () => {
- await apiQueryClient.prefetchQuery('physicalDiskList', {
- query: { limit: 10 },
- })
+ await apiQueryClient.prefetchQuery('physicalDiskListV1', { query: { limit: 10 } })
return null
}
export function DisksTab() {
- const { Table, Column } = useQueryTable('physicalDiskList', {})
+ const { Table, Column } = useQueryTable('physicalDiskListV1', {})
return (
<>
diff --git a/app/pages/system/InventoryPage/InventoryPage.tsx b/app/pages/system/InventoryPage/InventoryPage.tsx
index 2db9ad15e..2a170d790 100644
--- a/app/pages/system/InventoryPage/InventoryPage.tsx
+++ b/app/pages/system/InventoryPage/InventoryPage.tsx
@@ -7,14 +7,14 @@ import { RouteTabs, Tab } from 'app/components/RouteTabs'
import { pb } from 'app/util/path-builder'
InventoryPage.loader = async () => {
- await apiQueryClient.prefetchQuery('rackList', {
+ await apiQueryClient.prefetchQuery('rackListV1', {
query: { limit: 10 },
})
return null
}
export function InventoryPage() {
- const { data: racks } = useApiQuery('rackList', { query: { limit: 10 } })
+ const { data: racks } = useApiQuery('rackListV1', { query: { limit: 10 } })
const rack = racks?.items[0]
// TODO: Add loading state
diff --git a/app/pages/system/InventoryPage/RacksTab.tsx b/app/pages/system/InventoryPage/RacksTab.tsx
index ad100f494..15efc7804 100644
--- a/app/pages/system/InventoryPage/RacksTab.tsx
+++ b/app/pages/system/InventoryPage/RacksTab.tsx
@@ -13,14 +13,14 @@ const EmptyState = () => {
}
RacksTab.loader = async () => {
- await apiQueryClient.prefetchQuery('rackList', {
+ await apiQueryClient.prefetchQuery('rackListV1', {
query: { limit: 10 },
})
return null
}
export function RacksTab() {
- const { Table, Column } = useQueryTable('rackList', {})
+ const { Table, Column } = useQueryTable('rackListV1', {})
return (
<>
diff --git a/app/pages/system/InventoryPage/SledsTab.tsx b/app/pages/system/InventoryPage/SledsTab.tsx
index 19d7afbcf..fc410c186 100644
--- a/app/pages/system/InventoryPage/SledsTab.tsx
+++ b/app/pages/system/InventoryPage/SledsTab.tsx
@@ -13,14 +13,14 @@ const EmptyState = () => {
}
SledsTab.loader = async () => {
- await apiQueryClient.prefetchQuery('sledList', {
+ await apiQueryClient.prefetchQuery('sledListV1', {
query: { limit: 10 },
})
return null
}
export function SledsTab() {
- const { Table, Column } = useQueryTable('sledList', {}, { keepPreviousData: true })
+ const { Table, Column } = useQueryTable('sledListV1', {}, { keepPreviousData: true })
return (
<>
diff --git a/app/pages/system/SiloPage.tsx b/app/pages/system/SiloPage.tsx
index 74a25b15d..d5b1dfb1d 100644
--- a/app/pages/system/SiloPage.tsx
+++ b/app/pages/system/SiloPage.tsx
@@ -46,7 +46,10 @@ export function SiloPage() {
Identity providers
-
+
New provider
diff --git a/app/pages/system/SilosPage.tsx b/app/pages/system/SilosPage.tsx
index bdb114248..acc947b0b 100644
--- a/app/pages/system/SilosPage.tsx
+++ b/app/pages/system/SilosPage.tsx
@@ -68,7 +68,7 @@ export default function SilosPage() {
{ value: 'New silo', onSelect: () => navigate(pb.siloNew()) },
...(silos?.items || []).map((o) => ({
value: o.name,
- onSelect: () => navigate(pb.silo({ siloName: o.name })),
+ onSelect: () => navigate(pb.silo({ silo: o.name })),
navGroup: 'Go to silo',
})),
],
@@ -87,7 +87,7 @@ export default function SilosPage() {
} makeActions={makeActions}>
- pb.silo({ siloName }))} />
+ pb.silo({ silo }))} />
{
- await page.goto(pb.instances({ orgName, projectName }))
+ await page.goto(pb.instances({ organization: orgName, project: projectName }))
await page.locator('text="New Instance"').click()
await expectVisible(page, [
@@ -42,7 +42,13 @@ test('can invoke instance create form from instances page', async ({
await page.locator('button:has-text("Create instance")').click()
- await page.waitForURL(pb.instancePage({ orgName, projectName, instanceName }))
+ await page.waitForURL(
+ pb.instancePage({
+ organization: orgName,
+ project: projectName,
+ instance: instanceName,
+ })
+ )
await expectVisible(page, [
`h1:has-text("${instanceName}")`,
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index 5eaf67cca..83749d718 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -2,11 +2,11 @@ import { pb } from './path-builder'
// params can be the same for all of them because they only use what they need
const params = {
- orgName: 'a',
- projectName: 'b',
- instanceName: 'c',
- vpcName: 'd',
- siloName: 's',
+ organization: 'a',
+ project: 'b',
+ instance: 'c',
+ vpc: 'd',
+ silo: 's',
version: 'v',
}
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index 3b4cb391a..64e413e0c 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -1,24 +1,34 @@
import type { PathParams as PP } from '@oxide/api'
+// TODO: required versions of path params probably belong somewhere else,
+// they're useful
+
+type Org = Required
+type Project = Required
+type Instance = Required
+type Vpc = Required
+type SystemUpdate = Required
+type Silo = Required
+
export const pb = {
orgs: () => '/orgs',
orgNew: () => '/orgs-new',
- org: ({ orgName }: PP.Org) => `${pb.orgs()}/${orgName}`,
- orgEdit: (params: PP.Org) => `${pb.org(params)}/edit`,
- orgAccess: (params: PP.Org) => `${pb.org(params)}/access`,
+ org: ({ organization }: Org) => `${pb.orgs()}/${organization}`,
+ orgEdit: (params: Org) => `${pb.org(params)}/edit`,
+ orgAccess: (params: Org) => `${pb.org(params)}/access`,
- projects: (params: PP.Org) => `${pb.org(params)}/projects`,
- projectNew: (params: PP.Org) => `${pb.org(params)}/projects-new`,
- project: ({ orgName, projectName }: PP.Project) =>
- `${pb.projects({ orgName })}/${projectName}`,
- projectEdit: (params: PP.Project) => `${pb.project(params)}/edit`,
+ projects: (params: Org) => `${pb.org(params)}/projects`,
+ projectNew: (params: Org) => `${pb.org(params)}/projects-new`,
+ project: ({ organization, project }: Project) =>
+ `${pb.projects({ organization })}/${project}`,
+ projectEdit: (params: Project) => `${pb.project(params)}/edit`,
- projectAccess: (params: PP.Project) => `${pb.project(params)}/access`,
- projectImages: (params: PP.Project) => `${pb.project(params)}/images`,
+ projectAccess: (params: Project) => `${pb.project(params)}/access`,
+ projectImages: (params: Project) => `${pb.project(params)}/images`,
- instances: (params: PP.Project) => `${pb.project(params)}/instances`,
- instanceNew: (params: PP.Project) => `${pb.project(params)}/instances-new`,
- instance: (params: PP.Instance) => `${pb.instances(params)}/${params.instanceName}`,
+ instances: (params: Project) => `${pb.project(params)}/instances`,
+ instanceNew: (params: Project) => `${pb.project(params)}/instances-new`,
+ instance: (params: Instance) => `${pb.instances(params)}/${params.instance}`,
/**
* This route exists as a direct link to the default tab of the instance page. Unfortunately
@@ -27,25 +37,25 @@ export const pb = {
*
* @see https://github.com/oxidecomputer/console/pull/1267#discussion_r1016766205
*/
- instancePage: (params: PP.Instance) => pb.instanceStorage(params),
+ instancePage: (params: Instance) => pb.instanceStorage(params),
- instanceMetrics: (params: PP.Instance) => `${pb.instance(params)}/metrics`,
- instanceStorage: (params: PP.Instance) => `${pb.instance(params)}/storage`,
+ instanceMetrics: (params: Instance) => `${pb.instance(params)}/metrics`,
+ instanceStorage: (params: Instance) => `${pb.instance(params)}/storage`,
- nics: (params: PP.Instance) => `${pb.instance(params)}/network-interfaces`,
+ nics: (params: Instance) => `${pb.instance(params)}/network-interfaces`,
- serialConsole: (params: PP.Instance) => `${pb.instance(params)}/serial-console`,
+ serialConsole: (params: Instance) => `${pb.instance(params)}/serial-console`,
- diskNew: (params: PP.Project) => `${pb.project(params)}/disks-new`,
- disks: (params: PP.Project) => `${pb.project(params)}/disks`,
+ diskNew: (params: Project) => `${pb.project(params)}/disks-new`,
+ disks: (params: Project) => `${pb.project(params)}/disks`,
- snapshotNew: (params: PP.Project) => `${pb.project(params)}/snapshots-new`,
- snapshots: (params: PP.Project) => `${pb.project(params)}/snapshots`,
+ snapshotNew: (params: Project) => `${pb.project(params)}/snapshots-new`,
+ snapshots: (params: Project) => `${pb.project(params)}/snapshots`,
- vpcNew: (params: PP.Project) => `${pb.project(params)}/vpcs-new`,
- vpcs: (params: PP.Project) => `${pb.project(params)}/vpcs`,
- vpc: (params: PP.Vpc) => `${pb.vpcs(params)}/${params.vpcName}`,
- vpcEdit: (params: PP.Vpc) => `${pb.vpc(params)}/edit`,
+ vpcNew: (params: Project) => `${pb.project(params)}/vpcs-new`,
+ vpcs: (params: Project) => `${pb.project(params)}/vpcs`,
+ vpc: (params: Vpc) => `${pb.vpcs(params)}/${params.vpc}`,
+ vpcEdit: (params: Vpc) => `${pb.vpc(params)}/edit`,
siloUtilization: () => '/utilization',
siloAccess: () => '/access',
@@ -56,8 +66,7 @@ export const pb = {
systemHealth: () => '/sys/health',
systemUpdates: () => '/sys/update/updates',
- systemUpdateDetail: (params: PP.SystemUpdate) =>
- `${pb.systemUpdates()}/${params.version}`,
+ systemUpdateDetail: ({ version }: SystemUpdate) => `${pb.systemUpdates()}/${version}`,
systemUpdateHistory: () => '/sys/update/history',
updateableComponents: () => '/sys/update/components',
@@ -70,8 +79,8 @@ export const pb = {
silos: () => '/sys/silos',
siloNew: () => '/sys/silos-new',
- silo: ({ siloName }: PP.Silo) => `/sys/silos/${siloName}`,
- siloIdpNew: (params: PP.Silo) => `${pb.silo(params)}/idps-new`,
+ silo: ({ silo }: Silo) => `/sys/silos/${silo}`,
+ siloIdpNew: (params: Silo) => `${pb.silo(params)}/idps-new`,
settings: () => '/settings',
profile: () => '/settings/profile',
diff --git a/libs/api-mocks/json-type.type-spec.ts b/libs/api-mocks/json-type.type-spec.ts
index d5a391d81..a012beca5 100644
--- a/libs/api-mocks/json-type.type-spec.ts
+++ b/libs/api-mocks/json-type.type-spec.ts
@@ -1,26 +1,26 @@
+import { assertType } from 'vitest'
+
import type { VpcSubnet } from '@oxide/api'
import type { Json } from './json-type'
-// Tests of a sort. These expectType calls will fail to typecheck if the types
+// Tests of a sort. These assertType calls will fail to typecheck if the types
// are not equal. There's no point in wrapping this in a real test because it
// will always pass.
-const expectType = (_value: T) => {}
-
let val: any // eslint-disable-line @typescript-eslint/no-explicit-any
// just checking :)
-expectType<1>(val as 1)
+assertType<1>(val as 1)
// @ts-expect-error
-expectType<1>(val as 2)
+assertType<1>(val as 2)
// @ts-expect-error
-expectType<{ x: string }>(val as { x: number })
+assertType<{ x: string }>(val as { x: number })
-expectType(val as Json)
-expectType(val as Json)
-expectType<{ x: string; y: number }>(val as Json<{ x: Date; y: number }>)
-expectType<{ x: { a_b45_c: string }; z: string[] }>(
+assertType(val as Json)
+assertType(val as Json)
+assertType<{ x: string; y: number }>(val as Json<{ x: Date; y: number }>)
+assertType<{ x: { a_b45_c: string }; z: string[] }>(
val as Json<{ x: { aB45C: Date }; z: Date[] }>
)
@@ -35,4 +35,4 @@ type VpcSubnetJSON = {
vpc_id: string
}
-expectType(val as Json)
+assertType(val as Json)
diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts
index e18b49956..75c88f1af 100644
--- a/libs/api-mocks/msw/db.ts
+++ b/libs/api-mocks/msw/db.ts
@@ -1,6 +1,11 @@
+// note that isUuid checks for any kind of UUID. strictly speaking, we should
+// only be checking for v4
+import { validate as isUuid } from 'uuid'
+
import * as mock from '@oxide/api-mocks'
import type { ApiTypes as Api, PathParams as PP } from '@oxide/api'
import { user1 } from '@oxide/api-mocks'
+import { toApiSelector } from '@oxide/util'
import type { Json } from '../json-type'
import { clone, json } from './util'
@@ -9,128 +14,153 @@ const notFoundBody = { error_code: 'ObjectNotFound' } as const
export type NotFound = typeof notFoundBody
export const notFoundErr = json({ error_code: 'ObjectNotFound' } as const, { status: 404 })
-export const lookupById =
- (table: T[]) =>
- (params: { path: PP.Id }) => {
- const item = table.find((i) => i.id === params.path.id)
- if (!item) throw notFoundErr
- return item
- }
-
-export function lookupOrg(params: PP.Org): Json {
- const org = db.orgs.find((o) => o.name === params.orgName)
- if (!org) throw notFoundErr
- return org
+export const lookupById = (table: T[], id: string) => {
+ const item = table.find((i) => i.id === id)
+ if (!item) throw notFoundErr
+ return item
}
-export function lookupProject(params: PP.Project): Json {
- const org = lookupOrg(params)
+export const lookup = {
+ org({ organization: id }: PP.Org): Json {
+ if (!id) throw notFoundErr
- const project = db.projects.find(
- (p) => p.organization_id === org.id && p.name === params.projectName
- )
- if (!project) throw notFoundErr
+ if (isUuid(id)) return lookupById(db.orgs, id)
- return project
-}
+ const org = db.orgs.find((o) => o.name === id)
+ if (!org) throw notFoundErr
-export function lookupVpc(params: PP.Vpc): Json {
- const project = lookupProject(params)
+ return org
+ },
+ project({ project: id, ...orgSelector }: PP.Project): Json {
+ if (!id) throw notFoundErr
- const vpc = db.vpcs.find((p) => p.project_id === project.id && p.name === params.vpcName)
- if (!vpc) throw notFoundErr
+ if (isUuid(id)) return lookupById(db.projects, id)
- return vpc
-}
+ const org = lookup.org(orgSelector)
+ const project = db.projects.find((p) => p.organization_id === org.id && p.name === id)
+ if (!project) throw notFoundErr
-export function lookupInstance(params: PP.Instance): Json {
- const project = lookupProject(params)
+ return project
+ },
+ instance({ instance: id, ...projectSelector }: PP.Instance): Json {
+ if (!id) throw notFoundErr
- const instance = db.instances.find(
- (p) => p.project_id === project.id && p.name === params.instanceName
- )
- if (!instance) throw notFoundErr
+ if (isUuid(id)) return lookupById(db.instances, id)
- return instance
-}
+ const project = lookup.project(projectSelector)
+ const instance = db.instances.find((i) => i.project_id === project.id && i.name === id)
+ if (!instance) throw notFoundErr
-export function lookupNetworkInterface(
- params: PP.NetworkInterface
-): Json {
- const instance = lookupInstance(params)
+ return instance
+ },
+ networkInterface({
+ interface: id,
+ ...instanceSelector
+ }: PP.NetworkInterface): Json {
+ if (!id) throw notFoundErr
- const nic = db.networkInterfaces.find(
- (n) => n.instance_id === instance.id && n.name === params.interfaceName
- )
- if (!nic) throw notFoundErr
+ if (isUuid(id)) return lookupById(db.networkInterfaces, id)
- return nic
-}
+ const instance = lookup.instance(instanceSelector)
-export function lookupDisk(params: PP.Disk): Json {
- const project = lookupProject(params)
+ const nic = db.networkInterfaces.find(
+ (n) => n.instance_id === instance.id && n.name === id
+ )
+ if (!nic) throw notFoundErr
- const disk = db.disks.find(
- (d) => d.project_id === project.id && d.name === params.diskName
- )
- if (!disk) throw notFoundErr
+ return nic
+ },
+ disk({ disk: id, ...projectSelector }: PP.Disk): Json {
+ if (!id) throw notFoundErr
- return disk
-}
+ if (isUuid(id)) return lookupById(db.disks, id)
-export function lookupImage(params: PP.Image): Json {
- const project = lookupProject(params)
+ const project = lookup.project(projectSelector)
- const image = db.images.find(
- (d) => d.project_id === project.id && d.name === params.imageName
- )
- if (!image) throw notFoundErr
+ const disk = db.disks.find((d) => d.project_id === project.id && d.name === id)
+ if (!disk) throw notFoundErr
- return image
-}
+ return disk
+ },
+ snapshot({ snapshot: id, ...projectSelector }: PP.Snapshot): Json {
+ if (!id) throw notFoundErr
-export function lookupSnapshot(params: PP.Snapshot): Json {
- const project = lookupProject(params)
+ if (isUuid(id)) return lookupById(db.snapshots, id)
- const snapshot = db.snapshots.find(
- (s) => s.project_id === project.id && s.name === params.snapshotName
- )
- if (!snapshot) throw notFoundErr
+ const project = lookup.project(projectSelector)
+ const snapshot = db.snapshots.find((i) => i.project_id === project.id && i.name === id)
+ if (!snapshot) throw notFoundErr
- return snapshot
-}
+ return snapshot
+ },
+ vpc({ vpc: id, ...projectSelector }: PP.Vpc): Json {
+ console.log({ id, ...projectSelector })
+ if (!id) throw notFoundErr
-export function lookupVpcSubnet(params: PP.VpcSubnet): Json {
- const vpc = lookupVpc(params)
+ if (isUuid(id)) return lookupById(db.vpcs, id)
- const subnet = db.vpcSubnets.find(
- (p) => p.vpc_id === vpc.id && p.name === params.subnetName
- )
- if (!subnet) throw notFoundErr
+ const project = lookup.project(projectSelector)
+ const vpc = db.vpcs.find((v) => v.project_id === project.id && v.name === id)
+ if (!vpc) throw notFoundErr
- return subnet
-}
+ return vpc
+ },
+ vpcRouter({ router: id, ...vpcSelector }: PP.VpcRouter): Json {
+ if (!id) throw notFoundErr
-export function lookupVpcRouter(params: PP.VpcRouter): Json {
- const vpc = lookupVpc(params)
+ if (isUuid(id)) return lookupById(db.vpcRouters, id)
- const router = db.vpcRouters.find(
- (r) => r.vpc_id === vpc.id && r.name === params.routerName
- )
- if (!router) throw notFoundErr
+ const vpc = lookup.vpc(vpcSelector)
+ const router = db.vpcRouters.find((s) => s.vpc_id === vpc.id && s.name === id)
+ if (!router) throw notFoundErr
+
+ return router
+ },
+ vpcRouterRoute({ route: id, ...routerSelector }: PP.RouterRoute): Json {
+ if (!id) throw notFoundErr
+
+ if (isUuid(id)) return lookupById(db.vpcRouterRoutes, id)
+
+ const router = lookup.vpcRouter(routerSelector)
+ const route = db.vpcRouterRoutes.find(
+ (s) => s.vpc_router_id === router.id && s.name === id
+ )
+ if (!route) throw notFoundErr
+
+ return route
+ },
+ vpcSubnet({ subnet: id, ...vpcSelector }: PP.VpcSubnet): Json {
+ if (!id) throw notFoundErr
+
+ if (isUuid(id)) return lookupById(db.vpcSubnets, id)
+
+ const vpc = lookup.vpc(vpcSelector)
+ const subnet = db.vpcSubnets.find((s) => s.vpc_id === vpc.id && s.name === id)
+ if (!subnet) throw notFoundErr
- return router
+ return subnet
+ },
+ systemUpdate({ version }: PP.SystemUpdate): Json {
+ const update = db.systemUpdates.find((o) => o.version === version)
+ if (!update) throw notFoundErr
+ return update
+ },
+ sled(params: PP.Id): Json {
+ const sled = db.sleds.find((sled) => sled.id === params.id)
+ if (!sled) throw notFoundErr
+ return sled
+ },
}
-export function lookupVpcRouterRoute(params: PP.VpcRouterRoute): Json {
- const router = lookupVpcRouter(params)
+export function lookupImage(params: PP.Image): Json {
+ const project = lookup.project(toApiSelector(params))
- const route = db.vpcRouterRoutes.find(
- (r) => r.vpc_router_id === router.id && r.name === params.routeName
+ const image = db.images.find(
+ (d) => d.project_id === project.id && d.name === params.imageName
)
- if (!route) throw notFoundErr
+ if (!image) throw notFoundErr
- return route
+ return image
}
export function lookupGlobalImage(params: PP.GlobalImage): Json {
@@ -166,18 +196,6 @@ export function lookupSshKey(params: PP.SshKey): Json {
return sshKey
}
-export function lookupSled(params: PP.Id): Json {
- const sled = db.sleds.find((sled) => sled.id === params.id)
- if (!sled) throw notFoundErr
- return sled
-}
-
-export function lookupSystemUpdate(params: PP.SystemUpdate): Json {
- const update = db.systemUpdates.find((o) => o.version === params.version)
- if (!update) throw notFoundErr
- return update
-}
-
const initDb = {
disks: [...mock.disks],
globalImages: [...mock.globalImages],
diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts
index c14aafc8b..15026a3a4 100644
--- a/libs/api-mocks/msw/handlers.ts
+++ b/libs/api-mocks/msw/handlers.ts
@@ -3,7 +3,7 @@ import { v4 as uuid } from 'uuid'
import type { ApiTypes as Api, UpdateDeployment } from '@oxide/api'
import type { Json } from '@oxide/gen/msw-handlers'
import { json, makeHandlers } from '@oxide/gen/msw-handlers'
-import { pick, sortBy } from '@oxide/util'
+import { pick, sortBy, toApiSelector } from '@oxide/util'
import { genCumulativeI64Data, genI64Data } from '../metrics'
import { FLEET_ID } from '../role-assignment'
@@ -13,24 +13,13 @@ import { sortBySemverDesc } from '../update'
import { user1 } from '../user'
import {
db,
+ lookup,
lookupById,
- lookupDisk,
lookupGlobalImage,
lookupImage,
- lookupInstance,
- lookupNetworkInterface,
- lookupOrg,
- lookupProject,
lookupSamlIdp,
lookupSilo,
- lookupSled,
- lookupSnapshot,
lookupSshKey,
- lookupSystemUpdate,
- lookupVpc,
- lookupVpcRouter,
- lookupVpcRouterRoute,
- lookupVpcSubnet,
} from './db'
import {
NotImplemented,
@@ -53,8 +42,8 @@ export const handlers = makeHandlers({
deviceAccessToken: () => 200,
groupList: (params) => paginated(params.query, db.userGroups),
- organizationList: (params) => paginated(params.query, db.orgs),
- organizationCreate({ body }) {
+ organizationListV1: (params) => paginated(params.query, db.orgs),
+ organizationCreateV1({ body }) {
errIfExists(db.orgs, { name: body.name })
const newOrg: Json = {
@@ -66,15 +55,15 @@ export const handlers = makeHandlers({
return json(newOrg, { status: 201 })
},
- organizationView(params) {
- if (params.path.orgName.endsWith('-error-503')) {
+ organizationViewV1(params) {
+ if (params.path.organization.endsWith('-error-503')) {
throw unavailableErr
}
- return lookupOrg(params.path)
+ return lookup.org(params.path)
},
- organizationUpdate({ body, ...params }) {
- const org = lookupOrg(params.path)
+ organizationUpdateV1({ body, path }) {
+ const org = lookup.org(path)
if (typeof body.name === 'string') {
org.name = body.name
@@ -83,13 +72,13 @@ export const handlers = makeHandlers({
return org
},
- organizationDelete(params) {
- const org = lookupOrg(params.path)
+ organizationDeleteV1({ path }) {
+ const org = lookup.org(path)
db.orgs = db.orgs.filter((o) => o.id !== org.id)
return 204
},
- organizationPolicyView(params) {
- const org = lookupOrg(params.path)
+ organizationPolicyViewV1({ path }) {
+ const org = lookup.org(path)
const role_assignments = db.roleAssignments
.filter((r) => r.resource_type === 'organization' && r.resource_id === org.id)
@@ -97,8 +86,8 @@ export const handlers = makeHandlers({
return { role_assignments }
},
- organizationPolicyUpdate({ body, ...params }) {
- const org = lookupOrg(params.path)
+ organizationPolicyUpdateV1({ body, path }) {
+ const org = lookup.org(path)
const newAssignments = body.role_assignments.map((r) => ({
resource_type: 'organization' as const,
@@ -114,14 +103,13 @@ export const handlers = makeHandlers({
return body
},
- projectList(params) {
- const org = lookupOrg(params.path)
+ projectListV1(params) {
+ const org = lookup.org(params.query)
const projects = db.projects.filter((p) => p.organization_id === org.id)
-
return paginated(params.query, projects)
},
- projectCreate({ body, ...params }) {
- const org = lookupOrg(params.path)
+ projectCreateV1({ body, query }) {
+ const org = lookup.org(query)
errIfExists(db.projects, { name: body.name, organization_id: org.id })
const newProject: Json = {
@@ -134,9 +122,9 @@ export const handlers = makeHandlers({
return json(newProject, { status: 201 })
},
- projectView: (params) => lookupProject(params.path),
- projectUpdate({ body, ...params }) {
- const project = lookupProject(params.path)
+ projectViewV1: ({ path, query }) => lookup.project({ ...path, ...query }),
+ projectUpdateV1({ body, path, query }) {
+ const project = lookup.project({ ...path, ...query })
if (body.name) {
project.name = body.name
}
@@ -144,21 +132,21 @@ export const handlers = makeHandlers({
return project
},
- projectDelete(params) {
- const project = lookupProject(params.path)
+ projectDeleteV1({ path, query }) {
+ const project = lookup.project({ ...path, ...query })
db.projects = db.projects.filter((p) => p.id !== project.id)
return 204
},
- diskList(params) {
- const project = lookupProject(params.path)
+ diskListV1({ query }) {
+ const project = lookup.project(query)
const disks = db.disks.filter((d) => d.project_id === project.id)
- return paginated(params.query, disks)
+ return paginated(query, disks)
},
- diskCreate({ body, ...params }) {
- const project = lookupProject(params.path)
+ diskCreateV1({ body, query }) {
+ const project = lookup.project(query)
errIfExists(db.disks, { name: body.name, project_id: project.id })
@@ -180,9 +168,9 @@ export const handlers = makeHandlers({
return json(newDisk, { status: 201 })
},
- diskView: (params) => lookupDisk(params.path),
- diskDelete(params) {
- const disk = lookupDisk(params.path)
+ diskViewV1: ({ path, query }) => lookup.disk({ ...path, ...query }),
+ diskDeleteV1({ path, query }) {
+ const disk = lookup.disk({ ...path, ...query })
// Governed by https://github.com/oxidecomputer/omicron/blob/e5704d7f343fa0633751527dedf276409647ad4e/nexus/src/db/datastore.rs#L2103
switch (disk.state.state) {
@@ -196,10 +184,14 @@ export const handlers = makeHandlers({
db.disks = db.disks.filter((d) => d.id !== disk.id)
return 204
},
- diskMetricsList(params) {
- lookupDisk(params.path)
+ diskMetricsList({ path, query }) {
+ lookup.disk({
+ organization: path.orgName,
+ project: path.projectName,
+ disk: path.diskName,
+ })
- const { startTime, endTime } = getStartAndEndTime(params.query)
+ const { startTime, endTime } = getStartAndEndTime(query)
if (endTime <= startTime) return { items: [] }
@@ -212,12 +204,12 @@ export const handlers = makeHandlers({
}
},
imageList(params) {
- const project = lookupProject(params.path)
+ const project = lookup.project(toApiSelector(params.path))
const images = db.images.filter((i) => i.project_id === project.id)
return paginated(params.query, images)
},
imageCreate({ body, ...params }) {
- const project = lookupProject(params.path)
+ const project = lookup.project(toApiSelector(params.path))
errIfExists(db.images, { name: body.name, project_id: project.id })
const newImage: Json = {
@@ -238,13 +230,13 @@ export const handlers = makeHandlers({
return 204
},
- instanceList(params) {
- const project = lookupProject(params.path)
+ instanceListV1({ query }) {
+ const project = lookup.project(query)
const instances = db.instances.filter((i) => i.project_id === project.id)
- return paginated(params.query, instances)
+ return paginated(query, instances)
},
- instanceCreate({ body, ...params }) {
- const project = lookupProject(params.path)
+ instanceCreateV1({ body, query }) {
+ const project = lookup.project(query)
errIfExists(db.instances, { name: body.name, project_id: project.id })
@@ -257,9 +249,9 @@ export const handlers = makeHandlers({
for (const diskParams of body.disks || []) {
if (diskParams.type === 'create') {
errIfExists(db.disks, { name: diskParams.name, project_id: project.id })
- errIfInvalidDiskSize(params.path, diskParams)
+ errIfInvalidDiskSize(diskParams)
} else {
- lookupDisk({ ...params.path, diskName: diskParams.name })
+ lookup.disk({ ...query, disk: diskParams.name })
}
}
@@ -269,12 +261,8 @@ export const handlers = makeHandlers({
*/
if (body.network_interfaces?.type === 'create') {
body.network_interfaces.params.forEach(({ vpc_name, subnet_name }) => {
- lookupVpc({ ...params.path, vpcName: vpc_name })
- lookupVpcSubnet({
- ...params.path,
- vpcName: vpc_name,
- subnetName: subnet_name,
- })
+ lookup.vpc({ ...query, vpc: vpc_name })
+ lookup.vpcSubnet({ ...query, vpc: vpc_name, subnet: subnet_name })
})
}
@@ -294,7 +282,7 @@ export const handlers = makeHandlers({
}
db.disks.push(newDisk)
} else {
- const disk = lookupDisk({ ...params.path, diskName: diskParams.name })
+ const disk = lookup.disk({ ...query, disk: diskParams.name })
disk.state = { state: 'attached', instance: instanceId }
}
}
@@ -323,12 +311,9 @@ export const handlers = makeHandlers({
primary: i === 0 ? true : false,
mac: '00:00:00:00:00:00',
ip: ip || '127.0.0.1',
- vpc_id: lookupVpc({ ...params.path, vpcName: vpc_name }).id,
- subnet_id: lookupVpcSubnet({
- ...params.path,
- vpcName: vpc_name,
- subnetName: subnet_name,
- }).id,
+ vpc_id: lookup.vpc({ ...query, vpc: vpc_name }).id,
+ subnet_id: lookup.vpcSubnet({ ...query, vpc: vpc_name, subnet: subnet_name })
+ .id,
...getTimestamps(),
})
}
@@ -346,45 +331,44 @@ export const handlers = makeHandlers({
db.instances.push(newInstance)
return json(newInstance, { status: 201 })
},
- instanceView: (params) => lookupInstance(params.path),
- instanceDelete(params) {
- const instance = lookupInstance(params.path)
+ instanceViewV1: ({ path, query }) => lookup.instance({ ...path, ...query }),
+ instanceDeleteV1({ path, query }) {
+ const instance = lookup.instance({ ...path, ...query })
db.instances = db.instances.filter((i) => i.id !== instance.id)
return 204
},
- instanceDiskList(params) {
- const instance = lookupInstance(params.path)
+ instanceDiskListV1({ path, query }) {
+ const instance = lookup.instance({ ...path, ...query })
// TODO: Should disk instance state be `instance_id` instead of `instance`?
const disks = db.disks.filter(
(d) => 'instance' in d.state && d.state.instance === instance.id
)
- return paginated(params.query, disks)
+ return paginated(query, disks)
},
- instanceDiskAttach({ body, ...params }) {
- const instance = lookupInstance(params.path)
+ instanceDiskAttachV1({ body, path, query: projectParams }) {
+ const instance = lookup.instance({ ...path, ...projectParams })
if (instance.run_state !== 'stopped') {
throw 'Cannot attach disk to instance that is not stopped'
}
- const disk = lookupDisk({ ...params.path, diskName: body.name })
+ const disk = lookup.disk({ ...projectParams, disk: body.disk })
disk.state = {
state: 'attached',
instance: instance.id,
}
+ console.log(disk)
return disk
},
- instanceDiskDetach({ body, ...params }) {
- const instance = lookupInstance(params.path)
+ instanceDiskDetachV1({ body, path, query: projectParams }) {
+ const instance = lookup.instance({ ...path, ...projectParams })
if (instance.run_state !== 'stopped') {
throw 'Cannot detach disk to instance that is not stopped'
}
- const disk = lookupDisk({ ...params.path, diskName: body.name })
- disk.state = {
- state: 'detached',
- }
+ const disk = lookup.disk({ ...projectParams, disk: body.disk })
+ disk.state = { state: 'detached' }
return disk
},
instanceExternalIpList(params) {
- lookupInstance(params.path)
+ lookup.instance(toApiSelector(params.path)) // temporary
// TODO: proper mock table
return {
@@ -396,13 +380,13 @@ export const handlers = makeHandlers({
],
}
},
- instanceNetworkInterfaceList(params) {
- const instance = lookupInstance(params.path)
+ instanceNetworkInterfaceListV1({ query }) {
+ const instance = lookup.instance(query)
const nics = db.networkInterfaces.filter((n) => n.instance_id === instance.id)
- return paginated(params.query, nics)
+ return paginated(query, nics)
},
- instanceNetworkInterfaceCreate({ body, ...params }) {
- const instance = lookupInstance(params.path)
+ instanceNetworkInterfaceCreateV1({ body, query }) {
+ const instance = lookup.instance(query)
const nicsForInstance = db.networkInterfaces.filter(
(n) => n.instance_id === instance.id
)
@@ -410,13 +394,8 @@ export const handlers = makeHandlers({
const { name, description, subnet_name, vpc_name, ip } = body
- const vpc = lookupVpc({ ...params.path, vpcName: vpc_name })
-
- const subnet = lookupVpcSubnet({
- ...params.path,
- vpcName: vpc_name,
- subnetName: subnet_name,
- })
+ const vpc = lookup.vpc({ ...query, vpc: vpc_name })
+ const subnet = lookup.vpcSubnet({ ...query, vpc: vpc_name, subnet: subnet_name })
const newNic: Json = {
id: uuid(),
@@ -435,9 +414,10 @@ export const handlers = makeHandlers({
return newNic
},
- instanceNetworkInterfaceView: (params) => lookupNetworkInterface(params.path),
- instanceNetworkInterfaceUpdate({ body, ...params }) {
- const nic = lookupNetworkInterface(params.path)
+ instanceNetworkInterfaceViewV1: ({ path, query }) =>
+ lookup.networkInterface({ ...path, ...query }),
+ instanceNetworkInterfaceUpdateV1({ body, path, query }) {
+ const nic = lookup.networkInterface({ ...path, ...query })
if (body.name) {
nic.name = body.name
@@ -460,13 +440,13 @@ export const handlers = makeHandlers({
return nic
},
- instanceNetworkInterfaceDelete(params) {
- const nic = lookupNetworkInterface(params.path)
+ instanceNetworkInterfaceDeleteV1({ path, query }) {
+ const nic = lookup.networkInterface({ ...path, ...query })
db.networkInterfaces = db.networkInterfaces.filter((n) => n.id !== nic.id)
return 204
},
- instanceReboot(params) {
- const instance = lookupInstance(params.path)
+ instanceRebootV1({ path, query }) {
+ const instance = lookup.instance({ ...path, ...query })
instance.run_state = 'rebooting'
setTimeout(() => {
@@ -475,24 +455,24 @@ export const handlers = makeHandlers({
return json(instance, { status: 202 })
},
- instanceSerialConsole(_params) {
+ instanceSerialConsoleV1(_params) {
// TODO: Add support for params
return serial
},
- instanceStart(params) {
- const instance = lookupInstance(params.path)
+ instanceStartV1({ path, query }) {
+ const instance = lookup.instance({ ...path, ...query })
instance.run_state = 'running'
return json(instance, { status: 202 })
},
- instanceStop(params) {
- const instance = lookupInstance(params.path)
+ instanceStopV1({ path, query }) {
+ const instance = lookup.instance({ ...path, ...query })
instance.run_state = 'stopped'
return json(instance, { status: 202 })
},
- projectPolicyView(params) {
- const project = lookupProject(params.path)
+ projectPolicyViewV1({ path, query }) {
+ const project = lookup.project({ ...path, ...query })
const role_assignments = db.roleAssignments
.filter((r) => r.resource_type === 'project' && r.resource_id === project.id)
@@ -500,8 +480,8 @@ export const handlers = makeHandlers({
return { role_assignments }
},
- projectPolicyUpdate({ body, ...params }) {
- const project = lookupProject(params.path)
+ projectPolicyUpdateV1({ body, path, query }) {
+ const project = lookup.project({ ...path, ...query })
const newAssignments = body.role_assignments.map((r) => ({
resource_type: 'project' as const,
@@ -518,17 +498,17 @@ export const handlers = makeHandlers({
// TODO: Is this the right thing to return?
return body
},
- snapshotList(params) {
- const project = lookupProject(params.path)
+ snapshotListV1(params) {
+ const project = lookup.project(params.query)
const snapshots = db.snapshots.filter((i) => i.project_id === project.id)
return paginated(params.query, snapshots)
},
- snapshotCreate({ body, ...params }) {
- const project = lookupProject(params.path)
+ snapshotCreateV1({ body, query }) {
+ const project = lookup.project(query)
errIfExists(db.snapshots, { name: body.name })
- const disk = lookupDisk({ ...params.path, diskName: body.disk })
+ const disk = lookup.disk({ ...query, disk: body.disk })
const newSnapshot: Json = {
id: uuid(),
@@ -543,19 +523,19 @@ export const handlers = makeHandlers({
return json(newSnapshot, { status: 201 })
},
- snapshotView: (params) => lookupSnapshot(params.path),
- snapshotDelete(params) {
- const snapshot = lookupSnapshot(params.path)
+ snapshotViewV1: ({ path, query }) => lookup.snapshot({ ...path, ...query }),
+ snapshotDeleteV1({ path, query }) {
+ const snapshot = lookup.snapshot({ ...path, ...query })
db.snapshots = db.snapshots.filter((s) => s.id !== snapshot.id)
return 204
},
- vpcList(params) {
- const project = lookupProject(params.path)
+ vpcListV1({ query }) {
+ const project = lookup.project(query)
const vpcs = db.vpcs.filter((v) => v.project_id === project.id)
- return paginated(params.query, vpcs)
+ return paginated(query, vpcs)
},
- vpcCreate({ body, ...params }) {
- const project = lookupProject(params.path)
+ vpcCreateV1({ body, query }) {
+ const project = lookup.project(query)
errIfExists(db.vpcs, { name: body.name })
const newVpc: Json = {
@@ -583,9 +563,9 @@ export const handlers = makeHandlers({
return json(newVpc, { status: 201 })
},
- vpcView: (params) => lookupVpc(params.path),
- vpcUpdate({ body, ...params }) {
- const vpc = lookupVpc(params.path)
+ vpcViewV1: ({ path, query }) => lookup.vpc({ ...path, ...query }),
+ vpcUpdateV1({ body, path, query }) {
+ const vpc = lookup.vpc({ ...path, ...query })
if (body.name) {
vpc.name = body.name
@@ -600,8 +580,8 @@ export const handlers = makeHandlers({
}
return vpc
},
- vpcDelete(params) {
- const vpc = lookupVpc(params.path)
+ vpcDeleteV1({ path, query }) {
+ const vpc = lookup.vpc({ ...path, ...query })
db.vpcs = db.vpcs.filter((v) => v.id !== vpc.id)
db.vpcSubnets = db.vpcSubnets.filter((s) => s.vpc_id !== vpc.id)
@@ -618,13 +598,13 @@ export const handlers = makeHandlers({
return 204
},
vpcFirewallRulesView(params) {
- const vpc = lookupVpc(params.path)
+ const vpc = lookup.vpc(toApiSelector(params.path))
const rules = db.vpcFirewallRules.filter((r) => r.vpc_id === vpc.id)
return { rules: sortBy(rules, (r) => r.name) }
},
vpcFirewallRulesUpdate({ body, ...params }) {
- const vpc = lookupVpc(params.path)
+ const vpc = lookup.vpc(toApiSelector(params.path))
const rules = body.rules.map((rule) => ({
vpc_id: vpc.id,
@@ -641,13 +621,13 @@ export const handlers = makeHandlers({
return { rules: sortBy(rules, (r) => r.name) }
},
- vpcRouterList(params) {
- const vpc = lookupVpc(params.path)
+ vpcRouterListV1({ query }) {
+ const vpc = lookup.vpc(query)
const routers = db.vpcRouters.filter((r) => r.vpc_id === vpc.id)
- return paginated(params.query, routers)
+ return paginated(query, routers)
},
- vpcRouterCreate({ body, ...params }) {
- const vpc = lookupVpc(params.path)
+ vpcRouterCreateV1({ body, query }) {
+ const vpc = lookup.vpc(query)
errIfExists(db.vpcRouters, { vpc_id: vpc.id, name: body.name })
const newRouter: Json = {
@@ -660,9 +640,9 @@ export const handlers = makeHandlers({
db.vpcRouters.push(newRouter)
return json(newRouter, { status: 201 })
},
- vpcRouterView: (params) => lookupVpcRouter(params.path),
- vpcRouterUpdate({ body, ...params }) {
- const router = lookupVpcRouter(params.path)
+ vpcRouterViewV1: ({ path, query }) => lookup.vpcRouter({ ...path, ...query }),
+ vpcRouterUpdateV1({ body, path, query }) {
+ const router = lookup.vpcRouter({ ...path, ...query })
if (body.name) {
router.name = body.name
@@ -673,21 +653,21 @@ export const handlers = makeHandlers({
return router
},
- vpcRouterDelete(params) {
- const router = lookupVpcRouter(params.path)
+ vpcRouterDeleteV1({ path, query }) {
+ const router = lookup.vpcRouter({ ...path, ...query })
// TODO: Are there routers that can't be deleted?
db.vpcRouters = db.vpcRouters.filter((r) => r.id !== router.id)
return 204
},
- vpcRouterRouteList(params) {
- const router = lookupVpcRouter(params.path)
+ vpcRouterRouteListV1({ query }) {
+ const router = lookup.vpcRouter(query)
const routers = db.vpcRouterRoutes.filter((s) => s.vpc_router_id === router.id)
- return paginated(params.query, routers)
+ return paginated(query, routers)
},
- vpcRouterRouteCreate({ body, ...params }) {
- const router = lookupVpcRouter(params.path)
+ vpcRouterRouteCreateV1({ body, query }) {
+ const router = lookup.vpcRouter(query)
errIfExists(db.vpcRouterRoutes, { vpc_router_id: router.id, name: body.name })
@@ -700,9 +680,9 @@ export const handlers = makeHandlers({
}
return json(newRoute, { status: 201 })
},
- vpcRouterRouteView: (params) => lookupVpcRouterRoute(params.path),
- vpcRouterRouteUpdate({ body, ...params }) {
- const route = lookupVpcRouterRoute(params.path)
+ vpcRouterRouteViewV1: ({ path, query }) => lookup.vpcRouterRoute({ ...path, ...query }),
+ vpcRouterRouteUpdateV1({ body, path, query }) {
+ const route = lookup.vpcRouterRoute({ ...path, ...query })
if (route.kind !== 'custom') {
throw 'Only custom routes may be modified'
}
@@ -714,21 +694,21 @@ export const handlers = makeHandlers({
}
return route
},
- vpcRouterRouteDelete(params) {
- const route = lookupVpcRouterRoute(params.path)
+ vpcRouterRouteDeleteV1({ path, query }) {
+ const route = lookup.vpcRouterRoute({ ...path, ...query })
if (route.kind !== 'custom') {
throw 'Only custom routes may be modified'
}
db.vpcRouterRoutes = db.vpcRouterRoutes.filter((r) => r.id !== route.id)
return 204
},
- vpcSubnetList(params) {
- const vpc = lookupVpc(params.path)
+ vpcSubnetListV1({ query }) {
+ const vpc = lookup.vpc(query)
const subnets = db.vpcSubnets.filter((s) => s.vpc_id === vpc.id)
- return paginated(params.query, subnets)
+ return paginated(query, subnets)
},
- vpcSubnetCreate({ body, ...params }) {
- const vpc = lookupVpc(params.path)
+ vpcSubnetCreateV1({ body, query }) {
+ const vpc = lookup.vpc(query)
errIfExists(db.vpcSubnets, { vpc_id: vpc.id, name: body.name })
// TODO: Create a route for the subnet in the default router
@@ -745,9 +725,9 @@ export const handlers = makeHandlers({
db.vpcSubnets.push(newSubnet)
return json(newSubnet, { status: 201 })
},
- vpcSubnetView: (params) => lookupVpcSubnet(params.path),
- vpcSubnetUpdate({ body, ...params }) {
- const subnet = lookupVpcSubnet(params.path)
+ vpcSubnetViewV1: ({ path, query }) => lookup.vpcSubnet({ ...path, ...query }),
+ vpcSubnetUpdateV1({ body, path, query }) {
+ const subnet = lookup.vpcSubnet({ ...path, ...query })
if (body.name) {
subnet.name = body.name
@@ -758,26 +738,24 @@ export const handlers = makeHandlers({
return subnet
},
- vpcSubnetDelete(params) {
- const subnet = lookupVpcSubnet(params.path)
+ vpcSubnetDeleteV1({ path, query }) {
+ const subnet = lookup.vpcSubnet({ ...path, ...query })
db.vpcSubnets = db.vpcSubnets.filter((s) => s.id !== subnet.id)
return 204
},
vpcSubnetListNetworkInterfaces(params) {
- const subnet = lookupVpcSubnet(params.path)
+ const subnet = lookup.vpcSubnet(toApiSelector(params.path))
const nics = db.networkInterfaces.filter((n) => n.subnet_id === subnet.id)
return paginated(params.query, nics)
},
- sledPhysicalDiskList(params) {
- const sled = lookupSled({ id: params.path.sledId })
+ sledPhysicalDiskListV1({ path, query }) {
+ const sled = lookup.sled({ id: path.sledId })
const disks = db.physicalDisks.filter((n) => n.sled_id === sled.id)
- return paginated(params.query, disks)
- },
- physicalDiskList(params) {
- return paginated(params.query, db.physicalDisks)
+ return paginated(query, disks)
},
- policyView() {
+ physicalDiskListV1: ({ query }) => paginated(query, db.physicalDisks),
+ policyViewV1() {
// assume we're in the default silo
const siloId = defaultSilo.id
const role_assignments = db.roleAssignments
@@ -786,7 +764,7 @@ export const handlers = makeHandlers({
return { role_assignments }
},
- policyUpdate({ body }) {
+ policyUpdateV1({ body }) {
const siloId = defaultSilo.id
const newAssignments = body.role_assignments.map((r) => ({
resource_type: 'silo' as const,
@@ -802,9 +780,7 @@ export const handlers = makeHandlers({
return body
},
- rackList(params) {
- return paginated(params.query, db.racks)
- },
+ rackListV1: ({ query }) => paginated(query, db.racks),
sessionMe() {
return user1
},
@@ -836,7 +812,7 @@ export const handlers = makeHandlers({
db.sshKeys = db.sshKeys.filter((i) => i.id !== sshKey.id)
return 204
},
- sledList: (params) => paginated(params.query, db.sleds),
+ sledListV1: (params) => paginated(params.query, db.sleds),
systemImageList: (params) => paginated(params.query, db.globalImages),
systemImageCreate({ body }) {
errIfExists(db.globalImages, { name: body.name })
@@ -917,7 +893,7 @@ export const handlers = makeHandlers({
userList: (params) => paginated(params.query, db.users),
- systemPolicyView() {
+ systemPolicyViewV1() {
const role_assignments = db.roleAssignments
.filter((r) => r.resource_type === 'fleet' && r.resource_id === FLEET_ID)
.map((r) => pick(r, 'identity_id', 'identity_type', 'role_name'))
@@ -926,9 +902,9 @@ export const handlers = makeHandlers({
},
systemUpdateList: (params) => paginated(params.query, db.systemUpdates),
- systemUpdateView: ({ path }) => lookupSystemUpdate(path),
+ systemUpdateView: ({ path }) => lookup.systemUpdate(path),
systemUpdateComponentsList: (params) => {
- const systemUpdate = lookupSystemUpdate(params.path)
+ const systemUpdate = lookup.systemUpdate(params.path)
const ids = new Set(
db.systemUpdateComponentUpdates
.filter((o) => o.system_update_id === systemUpdate.id)
@@ -973,7 +949,7 @@ export const handlers = makeHandlers({
}
},
updateDeploymentsList: (params) => paginated(params.query, db.updateDeployments),
- updateDeploymentView: lookupById(db.updateDeployments),
+ updateDeploymentView: ({ path: { id } }) => lookupById(db.updateDeployments, id),
systemMetric: (params) => {
// const result = ZVal.ResourceName.safeParse(req.params.resourceName)
@@ -998,22 +974,6 @@ export const handlers = makeHandlers({
}
},
- // by ID endpoints (will be gone soon)
-
- diskViewById: lookupById(db.disks),
- imageViewById: lookupById(db.images),
- instanceNetworkInterfaceViewById: lookupById(db.networkInterfaces),
- instanceViewById: lookupById(db.instances),
- organizationViewById: lookupById(db.orgs),
- projectViewById: lookupById(db.projects),
- siloViewById: lookupById(db.silos),
- snapshotViewById: lookupById(db.snapshots),
- systemImageViewById: lookupById(db.globalImages),
- vpcRouterRouteViewById: lookupById(db.vpcRouterRoutes),
- vpcRouterViewById: lookupById(db.vpcRouters),
- vpcSubnetViewById: lookupById(db.vpcSubnets),
- vpcViewById: lookupById(db.vpcs),
-
// Misc endpoints we're not using yet in the console
certificateCreate: NotImplemented,
@@ -1043,7 +1003,6 @@ export const handlers = makeHandlers({
loginSamlBegin: NotImplemented,
loginSpoof: NotImplemented,
logout: NotImplemented,
- rackView: NotImplemented,
roleList: NotImplemented,
roleView: NotImplemented,
sagaList: NotImplemented,
@@ -1058,82 +1017,102 @@ export const handlers = makeHandlers({
systemUserView: NotImplemented,
timeseriesSchemaGet: NotImplemented,
- // V1 endpoints
+ // V1 endpoints we're not using in the console yet
certificateCreateV1: NotImplemented,
certificateDeleteV1: NotImplemented,
certificateListV1: NotImplemented,
certificateViewV1: NotImplemented,
- diskCreateV1: NotImplemented,
- diskDeleteV1: NotImplemented,
- diskListV1: NotImplemented,
- diskViewV1: NotImplemented,
- instanceCreateV1: NotImplemented,
- instanceDeleteV1: NotImplemented,
- instanceDiskAttachV1: NotImplemented,
- instanceDiskDetachV1: NotImplemented,
- instanceDiskListV1: NotImplemented,
- instanceListV1: NotImplemented,
instanceMigrateV1: NotImplemented,
- instanceNetworkInterfaceCreateV1: NotImplemented,
- instanceNetworkInterfaceDeleteV1: NotImplemented,
- instanceNetworkInterfaceListV1: NotImplemented,
- instanceNetworkInterfaceUpdateV1: NotImplemented,
- instanceNetworkInterfaceViewV1: NotImplemented,
- instanceRebootV1: NotImplemented,
instanceSerialConsoleStreamV1: NotImplemented,
- instanceSerialConsoleV1: NotImplemented,
- instanceStartV1: NotImplemented,
- instanceStopV1: NotImplemented,
- instanceViewV1: NotImplemented,
- organizationCreateV1: NotImplemented,
- organizationDeleteV1: NotImplemented,
- organizationListV1: NotImplemented,
- organizationPolicyUpdateV1: NotImplemented,
- organizationPolicyViewV1: NotImplemented,
- organizationUpdateV1: NotImplemented,
- organizationViewV1: NotImplemented,
- physicalDiskListV1: NotImplemented,
- policyUpdateV1: NotImplemented,
- policyViewV1: NotImplemented,
- projectCreateV1: NotImplemented,
- projectDeleteV1: NotImplemented,
- projectListV1: NotImplemented,
- projectPolicyUpdateV1: NotImplemented,
- projectPolicyViewV1: NotImplemented,
- projectUpdateV1: NotImplemented,
- projectViewV1: NotImplemented,
- rackListV1: NotImplemented,
rackViewV1: NotImplemented,
sagaListV1: NotImplemented,
sagaViewV1: NotImplemented,
- sledListV1: NotImplemented,
- sledPhysicalDiskListV1: NotImplemented,
sledViewV1: NotImplemented,
- snapshotCreateV1: NotImplemented,
- snapshotDeleteV1: NotImplemented,
- snapshotListV1: NotImplemented,
- snapshotViewV1: NotImplemented,
systemPolicyUpdateV1: NotImplemented,
- systemPolicyViewV1: NotImplemented,
- vpcCreateV1: NotImplemented,
- vpcDeleteV1: NotImplemented,
- vpcListV1: NotImplemented,
- vpcRouterCreateV1: NotImplemented,
- vpcRouterDeleteV1: NotImplemented,
- vpcRouterListV1: NotImplemented,
- vpcRouterRouteCreateV1: NotImplemented,
- vpcRouterRouteDeleteV1: NotImplemented,
- vpcRouterRouteListV1: NotImplemented,
- vpcRouterRouteUpdateV1: NotImplemented,
- vpcRouterRouteViewV1: NotImplemented,
- vpcRouterUpdateV1: NotImplemented,
- vpcRouterViewV1: NotImplemented,
- vpcSubnetCreateV1: NotImplemented,
- vpcSubnetDeleteV1: NotImplemented,
- vpcSubnetListV1: NotImplemented,
- vpcSubnetUpdateV1: NotImplemented,
- vpcSubnetViewV1: NotImplemented,
- vpcUpdateV1: NotImplemented,
- vpcViewV1: NotImplemented,
+
+ // deprecated by ID endpoints
+
+ diskViewById: NotImplemented,
+ imageViewById: NotImplemented,
+ instanceNetworkInterfaceViewById: NotImplemented,
+ instanceViewById: NotImplemented,
+ organizationViewById: NotImplemented,
+ projectViewById: NotImplemented,
+ siloViewById: NotImplemented,
+ snapshotViewById: NotImplemented,
+ systemImageViewById: NotImplemented,
+ vpcRouterRouteViewById: NotImplemented,
+ vpcRouterViewById: NotImplemented,
+ vpcSubnetViewById: NotImplemented,
+ vpcViewById: NotImplemented,
+
+ // Deprecated endpoints
+
+ diskCreate: NotImplemented,
+ diskDelete: NotImplemented,
+ diskList: NotImplemented,
+ diskView: NotImplemented,
+ instanceCreate: NotImplemented,
+ instanceDelete: NotImplemented,
+ instanceDiskAttach: NotImplemented,
+ instanceDiskDetach: NotImplemented,
+ instanceDiskList: NotImplemented,
+ instanceList: NotImplemented,
+ instanceNetworkInterfaceCreate: NotImplemented,
+ instanceNetworkInterfaceDelete: NotImplemented,
+ instanceNetworkInterfaceList: NotImplemented,
+ instanceNetworkInterfaceUpdate: NotImplemented,
+ instanceNetworkInterfaceView: NotImplemented,
+ instanceReboot: NotImplemented,
+ instanceSerialConsole: NotImplemented,
+ instanceStart: NotImplemented,
+ instanceStop: NotImplemented,
+ instanceView: NotImplemented,
+ organizationCreate: NotImplemented,
+ organizationDelete: NotImplemented,
+ organizationList: NotImplemented,
+ organizationPolicyUpdate: NotImplemented,
+ organizationPolicyView: NotImplemented,
+ organizationUpdate: NotImplemented,
+ organizationView: NotImplemented,
+ physicalDiskList: NotImplemented,
+ policyUpdate: NotImplemented,
+ policyView: NotImplemented,
+ projectCreate: NotImplemented,
+ projectDelete: NotImplemented,
+ projectList: NotImplemented,
+ projectPolicyUpdate: NotImplemented,
+ projectPolicyView: NotImplemented,
+ projectUpdate: NotImplemented,
+ projectView: NotImplemented,
+ rackList: NotImplemented,
+ rackView: NotImplemented,
+ sledList: NotImplemented,
+ sledPhysicalDiskList: NotImplemented,
+ snapshotCreate: NotImplemented,
+ snapshotDelete: NotImplemented,
+ snapshotList: NotImplemented,
+ snapshotView: NotImplemented,
+ systemPolicyView: NotImplemented,
+ vpcCreate: NotImplemented,
+ vpcDelete: NotImplemented,
+ vpcList: NotImplemented,
+ vpcRouterCreate: NotImplemented,
+ vpcRouterDelete: NotImplemented,
+ vpcRouterList: NotImplemented,
+ vpcRouterUpdate: NotImplemented,
+ vpcRouterView: NotImplemented,
+ vpcRouterRouteCreate: NotImplemented,
+ vpcRouterRouteDelete: NotImplemented,
+ vpcRouterRouteList: NotImplemented,
+ vpcRouterRouteUpdate: NotImplemented,
+ vpcRouterRouteView: NotImplemented,
+ vpcSubnetCreate: NotImplemented,
+ vpcSubnetDelete: NotImplemented,
+ vpcSubnetList: NotImplemented,
+ vpcSubnetUpdate: NotImplemented,
+ vpcSubnetView: NotImplemented,
+ vpcUpdate: NotImplemented,
+ vpcView: NotImplemented,
})
diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts
index 68424b738..b3a302ea4 100644
--- a/libs/api-mocks/msw/util.ts
+++ b/libs/api-mocks/msw/util.ts
@@ -1,6 +1,6 @@
import { subHours } from 'date-fns'
-import type { DiskCreate, DiskCreatePathParams } from '@oxide/api'
+import type { DiskCreate } from '@oxide/api'
import type { Json } from '@oxide/gen/msw-handlers'
import { json } from '@oxide/gen/msw-handlers'
import { GiB } from '@oxide/util'
@@ -92,10 +92,7 @@ export const errIfExists = >(
}
}
-export const errIfInvalidDiskSize = (
- params: DiskCreatePathParams,
- disk: Json
-) => {
+export const errIfInvalidDiskSize = (disk: Json) => {
const source = disk.disk_source
if (source.type === 'snapshot') {
const snapshotSize = db.snapshots.find((s) => source.snapshot_id === s.id)?.size ?? 0
diff --git a/libs/api/__tests__/hooks.spec.tsx b/libs/api/__tests__/hooks.spec.tsx
index ae8c4e7ad..c8a5fe587 100644
--- a/libs/api/__tests__/hooks.spec.tsx
+++ b/libs/api/__tests__/hooks.spec.tsx
@@ -26,16 +26,17 @@ export function Wrapper({ children }: { children: React.ReactNode }) {
const config = { wrapper: Wrapper }
-const renderGetOrgs = () => renderHook(() => useApiQuery('organizationList', {}), config)
+const renderGetOrgs = () => renderHook(() => useApiQuery('organizationListV1', {}), config)
// 503 is a special key in the MSW server that returns a 503
const renderGetOrg503 = () =>
renderHook(
- () => useApiQuery('organizationView', { path: { orgName: 'org-error-503' } }),
+ () => useApiQuery('organizationViewV1', { path: { organization: 'org-error-503' } }),
config
)
-const renderCreateOrg = () => renderHook(() => useApiMutation('organizationCreate'), config)
+const renderCreateOrg = () =>
+ renderHook(() => useApiMutation('organizationCreateV1'), config)
const createParams = {
body: { name: 'abc', description: '', hello: 'a' },
@@ -71,7 +72,7 @@ describe('useApiQuery', () => {
})
it('contains client_error if error body is not json', async () => {
- overrideOnce('get', '/api/organizations', 503, 'not json')
+ overrideOnce('get', '/api/v1/organizations', 503, 'not json')
const { result } = renderGetOrgs()
@@ -89,7 +90,7 @@ describe('useApiQuery', () => {
})
it('does not client_error if response body is empty', async () => {
- overrideOnce('get', '/api/organizations', 503, '')
+ overrideOnce('get', '/api/v1/organizations', 503, '')
const { result } = renderGetOrgs()
@@ -110,8 +111,8 @@ describe('useApiQuery', () => {
it('throws by default', async () => {
const { result } = renderHook(
() =>
- useApiQuery('organizationView', {
- path: { orgName: 'nonexistent' },
+ useApiQuery('organizationViewV1', {
+ path: { organization: 'nonexistent' },
}),
config
)
@@ -129,8 +130,8 @@ describe('useApiQuery', () => {
const { result } = renderHook(
() =>
useApiQuery(
- 'organizationView',
- { path: { orgName: 'nonexistent' } },
+ 'organizationViewV1',
+ { path: { organization: 'nonexistent' } },
{ useErrorBoundary: false } // <----- the point
),
config
@@ -156,7 +157,7 @@ describe('useApiQuery', () => {
// RQ doesn't like a value of undefined for data, so we're using {} for now
it('returns success with empty object if response body is empty', async () => {
- overrideOnce('get', '/api/organizations', 204, '')
+ overrideOnce('get', '/api/v1/organizations', 204, '')
const { result } = renderGetOrgs()
@@ -178,12 +179,12 @@ describe('useApiMutation', () => {
describe('on error response', () => {
const projectPost404Params = {
- path: { orgName: 'nonexistent' },
+ query: { organization: 'nonexistent' },
body: { name: 'will-fail', description: '' },
}
it('passes through raw response', async () => {
- const { result } = renderHook(() => useApiMutation('projectCreate'), config)
+ const { result } = renderHook(() => useApiMutation('projectCreateV1'), config)
act(() => result.current.mutate(projectPost404Params))
@@ -194,7 +195,7 @@ describe('useApiMutation', () => {
})
it('parses error json if possible', async () => {
- const { result } = renderHook(() => useApiMutation('projectCreate'), config)
+ const { result } = renderHook(() => useApiMutation('projectCreateV1'), config)
act(() => result.current.mutate(projectPost404Params))
@@ -206,7 +207,7 @@ describe('useApiMutation', () => {
})
it('contains client_error if error body is not json', async () => {
- overrideOnce('post', '/api/organizations', 404, 'not json')
+ overrideOnce('post', '/api/v1/organizations', 404, 'not json')
const { result } = renderCreateOrg()
act(() => result.current.mutate(createParams))
@@ -223,7 +224,7 @@ describe('useApiMutation', () => {
})
it('does not client_error if response body is empty', async () => {
- overrideOnce('post', '/api/organizations', 503, '')
+ overrideOnce('post', '/api/v1/organizations', 503, '')
const { result } = renderCreateOrg()
act(() => result.current.mutate(createParams))
@@ -255,7 +256,7 @@ describe('useApiMutation', () => {
// RQ doesn't like a value of undefined for data, so we're using {} for now
it('returns success with empty object if response body is empty', async () => {
- overrideOnce('post', '/api/organizations', 204, '')
+ overrideOnce('post', '/api/v1/organizations', 204, '')
const { result } = renderCreateOrg()
act(() => result.current.mutate(createParams))
diff --git a/libs/api/__tests__/nav-to-login.ts b/libs/api/__tests__/nav-to-login.spec.ts
similarity index 100%
rename from libs/api/__tests__/nav-to-login.ts
rename to libs/api/__tests__/nav-to-login.spec.ts
diff --git a/libs/api/errors.ts b/libs/api/errors.ts
index 5d5c79261..05bcdf133 100644
--- a/libs/api/errors.ts
+++ b/libs/api/errors.ts
@@ -2,6 +2,14 @@ import { camelCaseToWords, capitalize } from '@oxide/util'
import type { ErrorBody, ErrorResult } from '.'
+// assume a nice short resource name is the word before create
+export function getResourceName(method: string) {
+ const words = camelCaseToWords(method)
+ const i = words.indexOf('create')
+ if (i < 1) return null
+ return words[i - 1].replace(/s$/, '')
+}
+
const msgFromCode = (
method: string,
errorCode: string,
@@ -12,12 +20,13 @@ const msgFromCode = (
return 'Action not authorized'
// TODO: This is a temporary fix for the API; better messages should be provided from there
- case 'ObjectAlreadyExists':
- if (method.endsWith('Create')) {
- const resource = camelCaseToWords(method).slice(-2)[0].replace(/s$/, '')
+ case 'ObjectAlreadyExists': {
+ const resource = getResourceName(method)
+ if (resource) {
return `${capitalize(resource)} name already exists`
}
return undefined
+ }
default:
return undefined
}
@@ -128,4 +137,14 @@ if (import.meta.vitest) {
expect(formatServerError('', alreadyExists()).error.message).toEqual('whatever')
})
})
+
+ it.each([
+ ['projectCreate', 'project'],
+ ['projectCreateV1', 'project'],
+ ['instanceNetworkInterfaceCreate', 'interface'],
+ ['instanceNetworkInterfaceCreateV1', 'interface'],
+ ['doesNotContainC-reate', null],
+ ])('getResourceName gets resource names', (method, resource) => {
+ expect(getResourceName(method)).toEqual(resource)
+ })
}
diff --git a/libs/api/path-params.ts b/libs/api/path-params.ts
index fe8650449..dc9d9d78a 100644
--- a/libs/api/path-params.ts
+++ b/libs/api/path-params.ts
@@ -1,22 +1,24 @@
import type { Merge } from 'type-fest'
-export type Org = { orgName: string }
-export type Project = Merge
-export type Vpc = Merge
-export type Instance = Merge
-export type NetworkInterface = Merge
-export type Disk = Merge
-export type Image = Merge
-export type Snapshot = Merge
-export type DiskMetric = Merge
-export type VpcSubnet = Merge
-export type VpcRouter = Merge
-export type VpcRouterRoute = Merge
-export type SshKey = { sshKeyName: string }
+export type Org = { organization?: string }
+export type Project = Merge
+export type Instance = Merge
+export type Disk = Merge
+export type NetworkInterface = Merge
+export type Snapshot = Merge
+export type Vpc = Merge
+export type VpcSubnet = Merge
+export type VpcRouter = Merge
+export type RouterRoute = Merge
+export type SystemUpdate = { version: string }
+export type SiloV1 = { silo: string }
+
+export type Id = { id: string }
+
+// Not yet converted to v1
+
+export type Image = { orgName: string; projectName: string; imageName: string }
export type GlobalImage = { imageName: string }
export type Silo = { siloName: string }
export type IdentityProvider = Merge
-export type Id = { id: string }
-export type SystemMetric = { resourceName: string }
-export type PhysicalDisk = { sledId: string }
-export type SystemUpdate = { version: string }
+export type SshKey = { sshKeyName: string }
diff --git a/libs/util/index.ts b/libs/util/index.ts
index b41aa6449..c56ded77b 100644
--- a/libs/util/index.ts
+++ b/libs/util/index.ts
@@ -3,5 +3,6 @@ export * from './children'
export * from './classed'
export * from './date'
export * from './object'
+export * from './selector'
export * from './str'
export * from './units'
diff --git a/libs/util/selector.spec.ts b/libs/util/selector.spec.ts
new file mode 100644
index 000000000..543b4ad5a
--- /dev/null
+++ b/libs/util/selector.spec.ts
@@ -0,0 +1,51 @@
+import { assertType } from 'vitest'
+
+import { toApiSelector, toPathQuery } from './selector'
+
+describe('toPathQuery', () => {
+ it('works in the base case', () => {
+ const result = toPathQuery('instance', {
+ instance: 'i',
+ project: 'p',
+ organization: 'o',
+ })
+ expect(result).toEqual({
+ path: { instance: 'i' },
+ query: { project: 'p', organization: 'o' },
+ })
+
+ // with nice type inference
+ assertType<{
+ path: { instance: string }
+ query: { project: string; organization: string }
+ }>(result)
+ })
+
+ it('leaves an empty query in there when there is only the one key', () => {
+ expect(toPathQuery('instance', { instance: 'i' })).toEqual({
+ path: { instance: 'i' },
+ query: {},
+ })
+ })
+
+ it('type errors on missing key', () => {
+ // type error if key is not in the object
+ // @ts-expect-error
+ toPathQuery('instance', { instanc: 'i', project: 'p', organization: 'o' })
+ })
+})
+
+describe('toApiSelector', () => {
+ it('converts xName to x, handling orgName specially', () => {
+ const result = toApiSelector({ orgName: 'abc', projectName: 'def' })
+ expect(result).toEqual({ organization: 'abc', project: 'def' })
+
+ // make sure it gets the type right
+ assertType<{ organization: string; project: string }>(result)
+ })
+
+ it('type errors on keys that do not end in Name', () => {
+ // @ts-expect-error keys must end in 'Name'
+ toApiSelector({ projectNam: 'abc' })
+ })
+})
diff --git a/libs/util/selector.ts b/libs/util/selector.ts
new file mode 100644
index 000000000..e218dbd51
--- /dev/null
+++ b/libs/util/selector.ts
@@ -0,0 +1,49 @@
+import type { Replace } from 'type-fest'
+
+import { exclude } from '@oxide/util'
+
+/**
+ * Convert a selector to API params with one key in `path` and the rest in
+ * `query`. To add extra query params like `limit`, just include them in
+ * `selector` and they'll end up in `query` like everything else.
+ *
+ * Mapped types are hard to read here, but they give the calling code nice
+ * inference.
+ */
+export const toPathQuery = (
+ pathKey: PK,
+ selector: Record
+) => ({
+ path: { [pathKey]: selector[pathKey] } as { [K0 in PK]: string },
+ query: exclude(selector, pathKey) as { [K0 in Exclude]: string },
+})
+
+type StripName = K extends `${infer K0}Name` ? K0 : K
+
+/**
+ * Turn
+ *
+ * ```ts
+ * { orgName: 'abc', projectName: 'def' }
+ * ```
+ * into
+ * ```ts
+ * { organization: 'abc', project: 'def' }
+ * ```
+ *
+ * while maintaining type-level awareness of keys. Note special handling of
+ * `orgName` to avoid having to convert hundreds of lines of existing code to
+ * use `organizationName`. Organizations are going to disappear anyway, which
+ * will make this unnecessary.
+ */
+export function toApiSelector(selector: Record) {
+ return Object.fromEntries(
+ Object.entries(selector).map(([k, v]) => [
+ k === 'orgName' ? 'organization' : stripName(k as K),
+ v,
+ ])
+ ) as { [K1 in StripName>]: string }
+}
+
+const stripName = (nameKey: `${K}Name`) =>
+ nameKey.replace(/Name$/, '') as StripName