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