]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add support for managing RBD QoS
authorPatrick Nawracay <pnawracay@suse.com>
Mon, 29 Oct 2018 08:59:59 +0000 (09:59 +0100)
committerPatrick Nawracay <pnawracay@suse.com>
Tue, 19 Feb 2019 10:08:59 +0000 (11:08 +0100)
Fixes: http://tracker.ceph.com/issues/36191
Signed-off-by: Patrick Nawracay <pnawracay@suse.com>
62 files changed:
qa/suites/rados/mgr/tasks/dashboard.yaml
qa/tasks/mgr/dashboard/test_rbd.py
src/pybind/mgr/dashboard/controllers/__init__.py
src/pybind/mgr/dashboard/controllers/pool.py
src/pybind/mgr/dashboard/controllers/rbd.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-clone-request.model.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-copy-request.model.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.model.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-images/rbd-images.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-details/pool-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/configuration.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-second.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf
src/pybind/mgr/dashboard/frontend/src/styles.scss
src/pybind/mgr/dashboard/services/rbd.py [new file with mode: 0644]
src/pybind/mgr/dashboard/tools.py

index 0c2dfc880404ca80b610d4721781856106ed3407..d24cdf63a6a25bdbbf0401814ad2ef7b538cdc83 100644 (file)
@@ -20,6 +20,7 @@ tasks:
         - \(MDS_UP_LESS_THAN_MAX\)
         - \(OSD_DOWN\)
         - \(OSD_HOST_DOWN\)
+        - \(POOL_APP_NOT_ENABLED\)
         - pauserd,pausewr flag\(s\) set
         - Monitor daemon marked osd\.[[:digit:]]+ down, but it is still running
   - rgw: [client.0]
index 7d8773cb031e5f20dcae812b6eb4eb648e8aebb6..f9b7dd022fa65fa90ebca932a13540a2859747d7 100644 (file)
@@ -89,9 +89,9 @@ class RbdTest(DashboardTestCase):
 
     # pylint: disable=too-many-arguments
     @classmethod
-    def edit_image(cls, pool, image, name=None, size=None, features=None):
-        return cls._task_put('/api/block/image/{}/{}'.format(pool, image),
-                             {'name': name, 'size': size, 'features': features})
+    def edit_image(cls, pool, image, name=None, size=None, features=None, **kwargs):
+        kwargs.update({'name': name, 'size': size, 'features': features})
+        return cls._task_put('/api/block/image/{}/{}'.format(pool, image), kwargs)
 
     @classmethod
     def flatten_image(cls, pool, image):
@@ -139,7 +139,7 @@ class RbdTest(DashboardTestCase):
                        '--yes-i-really-really-mean-it'])
 
     @classmethod
-    def create_image_in_trash(cls, pool, name, delay=0, **kwargs):
+    def create_image_in_trash(cls, pool, name, delay=0):
         cls.create_image(pool, name, 10240)
         img = cls._get('/api/block/image/{}/{}'.format(pool, name))
 
@@ -201,6 +201,11 @@ class RbdTest(DashboardTestCase):
             'timestamp': JLeaf(str, none=True),
             'disk_usage': JLeaf(int, none=True),
             'total_disk_usage': JLeaf(int, none=True),
+            'configuration': JList(JObj(sub_elems={
+                'name': JLeaf(str),
+                'source': JLeaf(int),
+                'value': JLeaf(str),
+            })),
         })
         self.assertSchema(img, schema)
 
@@ -288,6 +293,33 @@ class RbdTest(DashboardTestCase):
 
         self.remove_image('rbd', rbd_name)
 
+    def test_create_with_configuration(self):
+        pool = 'rbd'
+        image_name = 'image_with_config'
+        size = 10240
+        configuration = {
+            'rbd_qos_bps_limit': 10240,
+            'rbd_qos_bps_burst': 10240 * 2,
+        }
+        expected = [{
+            'name': 'rbd_qos_bps_limit',
+            'source': 2,
+            'value': str(10240),
+        }, {
+            'name': 'rbd_qos_bps_burst',
+            'source': 2,
+            'value': str(10240 * 2),
+        }]
+
+        self.create_image(pool, image_name, size, configuration=configuration)
+        self.assertStatus(201)
+        img = self._get('/api/block/image/rbd/{}'.format(image_name))
+        self.assertStatus(200)
+        for conf in expected:
+            self.assertIn(conf, img['configuration'])
+
+        self.remove_image(pool, image_name)
+
     def test_create_rbd_in_data_pool(self):
         if not self.bluestore_support:
             self.skipTest('requires bluestore cluster')
@@ -460,6 +492,57 @@ class RbdTest(DashboardTestCase):
         self.remove_image('rbd', 'edit_img')
         self.assertStatus(204)
 
+    def test_image_change_config(self):
+        pool = 'rbd'
+        image = 'image_with_config'
+        initial_conf = {
+            'rbd_qos_bps_limit': 10240,
+            'rbd_qos_write_iops_limit': None
+        }
+        initial_expect = [{
+            'name': 'rbd_qos_bps_limit',
+            'source': 2,
+            'value': '10240',
+        }, {
+            'name': 'rbd_qos_write_iops_limit',
+            'source': 0,
+            'value': '0',
+        }]
+        new_conf = {
+            'rbd_qos_bps_limit': 0,
+            'rbd_qos_bps_burst': 20480,
+            'rbd_qos_write_iops_limit': None
+        }
+        new_expect = [{
+            'name': 'rbd_qos_bps_limit',
+            'source': 2,
+            'value': '0',
+        }, {
+            'name': 'rbd_qos_bps_burst',
+            'source': 2,
+            'value': '20480',
+        }, {
+            'name': 'rbd_qos_write_iops_limit',
+            'source': 0,
+            'value': '0',
+        }]
+
+        self.create_image(pool, image, 2**30, configuration=initial_conf)
+        self.assertStatus(201)
+        img = self._get('/api/block/image/{}/{}'.format(pool, image))
+        self.assertStatus(200)
+        for conf in initial_expect:
+            self.assertIn(conf, img['configuration'])
+
+        self.edit_image(pool, image, configuration=new_conf)
+        img = self._get('/api/block/image/{}/{}'.format(pool, image))
+        self.assertStatus(200)
+        for conf in new_expect:
+            self.assertIn(conf, img['configuration'])
+
+        self.remove_image(pool, image)
+        self.assertStatus(204)
+
     def test_update_snapshot(self):
         self.create_snapshot('rbd', 'img1', 'snap5')
         self.assertStatus(201)
index e8839a43b3cba3d1046971180f405b593d043470..33964663868182934241127a899a224dc264dcbe 100644 (file)
@@ -317,24 +317,24 @@ class Task(object):
         @wraps(func)
         def wrapper(*args, **kwargs):
             arg_map = self._gen_arg_map(func, args, kwargs)
-            md = {}
+            metadata = {}
             for k, v in self.metadata.items():
                 if isinstance(v, str) and v and v[0] == '{' and v[-1] == '}':
                     param = v[1:-1]
                     try:
                         pos = int(param)
-                        md[k] = arg_map[pos]
+                        metadata[k] = arg_map[pos]
                     except ValueError:
                         if param.find('.') == -1:
-                            md[k] = arg_map[param]
+                            metadata[k] = arg_map[param]
                         else:
                             path = param.split('.')
-                            md[k] = arg_map[path[0]]
+                            metadata[k] = arg_map[path[0]]
                             for i in range(1, len(path)):
-                                md[k] = md[k][path[i]]
+                                metadata[k] = metadata[k][path[i]]
                 else:
-                    md[k] = v
-            task = TaskManager.run(self.name, md, func, args, kwargs,
+                    metadata[k] = v
+            task = TaskManager.run(self.name, metadata, func, args, kwargs,
                                    exception_handler=self.exception_handler)
             try:
                 status, value = task.wait(self.wait_for)
@@ -350,7 +350,7 @@ class Task(object):
                 raise ex
             if status == TaskManager.VALUE_EXECUTING:
                 cherrypy.response.status = 202
-                return {'name': self.name, 'metadata': md}
+                return {'name': self.name, 'metadata': metadata}
             return value
         return wrapper
 
index cdd9a7110d45d1684feb76d544fa4f35c032caf4..b20e19842f1732cd8cc4b3387eafca679ddc5449 100644 (file)
@@ -8,6 +8,7 @@ from . import ApiController, RESTController, Endpoint, ReadPermission, Task
 from .. import mgr
 from ..security import Scope
 from ..services.ceph_service import CephService
+from ..services.rbd import RbdConfiguration
 from ..services.exception import handle_send_command_error
 from ..tools import str_to_bool
 
@@ -67,7 +68,9 @@ class Pool(RESTController):
 
     def get(self, pool_name, attrs=None, stats=False):
         # type: (str, str, bool) -> dict
-        return self._get(pool_name, attrs, stats)
+        pool = self._get(pool_name, attrs, stats)
+        pool['configuration'] = RbdConfiguration(pool_name).list()
+        return pool
 
     @pool_task('delete', ['{pool_name}'])
     @handle_send_command_error('pool')
@@ -76,19 +79,20 @@ class Pool(RESTController):
                                         yes_i_really_really_mean_it=True)
 
     @pool_task('edit', ['{pool_name}'])
-    def set(self, pool_name, flags=None, application_metadata=None, **kwargs):
+    def set(self, pool_name, flags=None, application_metadata=None, configuration=None, **kwargs):
         self._set_pool_values(pool_name, application_metadata, flags, True, kwargs)
+        RbdConfiguration(pool_name).set_configuration(configuration)
 
     @pool_task('create', {'pool_name': '{pool}'})
     @handle_send_command_error('pool')
     def create(self, pool, pg_num, pool_type, erasure_code_profile=None, flags=None,
-               application_metadata=None, rule_name=None, **kwargs):
+               application_metadata=None, rule_name=None, configuration=None, **kwargs):
         ecp = erasure_code_profile if erasure_code_profile else None
         CephService.send_command('mon', 'osd pool create', pool=pool, pg_num=int(pg_num),
                                  pgp_num=int(pg_num), pool_type=pool_type, erasure_code_profile=ecp,
                                  rule=rule_name)
-
         self._set_pool_values(pool, application_metadata, flags, False, kwargs)
+        RbdConfiguration(pool).set_configuration(configuration)
 
     def _set_pool_values(self, pool, application_metadata, flags, update_existing, kwargs):
         update_name = False
@@ -137,10 +141,17 @@ class Pool(RESTController):
                 reset_arg(arg, '0')
             reset_arg('compression_algorithm', 'unset')
 
+    @RESTController.Resource()
+    @ReadPermission
+    def configuration(self, pool_name):
+        return RbdConfiguration(pool_name).list()
+
     @Endpoint()
     @ReadPermission
-    def _info(self):
+    def _info(self, pool_name=''):
+        # type: (str) -> dict
         """Used by the create-pool dialog"""
+
         def rules(pool_type):
             return [r
                     for r in mgr.get('osd_map_crush')['rules']
@@ -155,7 +166,7 @@ class Pool(RESTController):
                     for o in mgr.get('config_options')['options']
                     if o['name'] == conf_name][0]
 
-        return {
+        result = {
             "pool_names": [p['pool_name'] for p in self._pool_list()],
             "crush_rules_replicated": rules(1),
             "crush_rules_erasure": rules(3),
@@ -165,3 +176,8 @@ class Pool(RESTController):
             "compression_algorithms": compression_enum('bluestore_compression_algorithm'),
             "compression_modes": compression_enum('bluestore_compression_mode'),
         }
+
+        if pool_name:
+            result['pool_options'] = RbdConfiguration(pool_name).list()
+
+        return result
index f16f7b56c5c84a1ebd4886cab989b940295bdc7a..14a751b93c2c15efdad7052fbd87b72ee7a07345 100644 (file)
@@ -13,10 +13,11 @@ import six
 import rbd
 
 from . import ApiController, RESTController, Task, UpdatePermission, \
-              DeletePermission, CreatePermission
+              DeletePermission, CreatePermission, ReadPermission
 from .. import mgr
 from ..security import Scope
 from ..services.ceph_service import CephService
+from ..services.rbd import RbdConfiguration
 from ..tools import ViewCache, str_to_bool
 from ..services.exception import handle_rados_error, handle_rbd_error, \
                                  serialize_dashboard_exception
@@ -121,12 +122,11 @@ class Rbd(RESTController):
     RESOURCE_ID = "pool_name/image_name"
 
     # set of image features that can be enable on existing images
-    ALLOW_ENABLE_FEATURES = set(["exclusive-lock", "object-map", "fast-diff",
-                                 "journaling"])
+    ALLOW_ENABLE_FEATURES = {"exclusive-lock", "object-map", "fast-diff", "journaling"}
 
     # set of image features that can be disabled on existing images
-    ALLOW_DISABLE_FEATURES = set(["exclusive-lock", "object-map", "fast-diff",
-                                  "deep-flatten", "journaling"])
+    ALLOW_DISABLE_FEATURES = {"exclusive-lock", "object-map", "fast-diff", "deep-flatten",
+                              "journaling"}
 
     @classmethod
     def _rbd_disk_usage(cls, image, snaps, whole_object=True):
@@ -152,7 +152,8 @@ class Rbd(RESTController):
 
         return total_used_size, snap_map
 
-    def _rbd_image(self, ioctx, pool_name, image_name):
+    @classmethod
+    def _rbd_image(cls, ioctx, pool_name, image_name):
         with rbd.Image(ioctx, image_name) as img:
             stat = img.stat()
             stat['name'] = image_name
@@ -213,7 +214,7 @@ class Rbd(RESTController):
                          for s in stat['snapshots']]
                 snaps.sort(key=lambda s: s[0])
                 snaps += [(snaps[-1][0]+1 if snaps else 0, stat['size'], None)]
-                total_prov_bytes, snaps_prov_bytes = self._rbd_disk_usage(
+                total_prov_bytes, snaps_prov_bytes = cls._rbd_disk_usage(
                     img, snaps, True)
                 stat['total_disk_usage'] = total_prov_bytes
                 for snap, prov_bytes in snaps_prov_bytes.items():
@@ -228,17 +229,20 @@ class Rbd(RESTController):
                 stat['total_disk_usage'] = None
                 stat['disk_usage'] = None
 
+            stat['configuration'] = RbdConfiguration(pool_ioctx=ioctx, image_name=image_name).list()
+
             return stat
 
+    @classmethod
     @ViewCache()
-    def _rbd_pool_list(self, pool_name):
+    def _rbd_pool_list(cls, pool_name):
         rbd_inst = rbd.RBD()
         with mgr.rados.open_ioctx(pool_name) as ioctx:
             names = rbd_inst.list(ioctx)
             result = []
             for name in names:
                 try:
-                    stat = self._rbd_image(ioctx, pool_name, name)
+                    stat = cls._rbd_image(ioctx, pool_name, name)
                 except rbd.ImageNotFound:
                     # may have been removed in the meanwhile
                     continue
@@ -255,6 +259,8 @@ class Rbd(RESTController):
         for pool in pools:
             # pylint: disable=unbalanced-tuple-unpacking
             status, value = self._rbd_pool_list(pool)
+            for i, image in enumerate(value):
+                value[i]['configuration'] = RbdConfiguration(pool, image['name']).list()
             result.append({'status': status, 'value': value, 'pool_name': pool})
         return result
 
@@ -275,7 +281,7 @@ class Rbd(RESTController):
     @RbdTask('create',
              {'pool_name': '{pool_name}', 'image_name': '{name}'}, 2.0)
     def create(self, name, pool_name, size, obj_size=None, features=None,
-               stripe_unit=None, stripe_count=None, data_pool=None):
+               stripe_unit=None, stripe_count=None, data_pool=None, configuration=None):
 
         size = int(size)
 
@@ -293,8 +299,9 @@ class Rbd(RESTController):
             rbd_inst.create(ioctx, name, size, order=l_order, old_format=False,
                             features=feature_bitmask, stripe_unit=stripe_unit,
                             stripe_count=stripe_count, data_pool=data_pool)
+            RbdConfiguration(pool_ioctx=ioctx, image_name=name).set_configuration(configuration)
 
-        return _rbd_call(pool_name, _create)
+        _rbd_call(pool_name, _create)
 
     @RbdTask('delete', ['{pool_name}', '{image_name}'], 2.0)
     def delete(self, pool_name, image_name):
@@ -302,7 +309,7 @@ class Rbd(RESTController):
         return _rbd_call(pool_name, rbd_inst.remove, image_name)
 
     @RbdTask('edit', ['{pool_name}', '{image_name}', '{name}'], 4.0)
-    def set(self, pool_name, image_name, name=None, size=None, features=None):
+    def set(self, pool_name, image_name, name=None, size=None, features=None, configuration=None):
         def _edit(ioctx, image):
             rbd_inst = rbd.RBD()
             # check rename image
@@ -329,6 +336,9 @@ class Rbd(RESTController):
                         f_bitmask = _format_features([feature])
                         image.update_features(f_bitmask, True)
 
+            RbdConfiguration(pool_ioctx=ioctx, image_name=image_name).set_configuration(
+                configuration)
+
         return _rbd_image_call(pool_name, image_name, _edit)
 
     @RbdTask('copy',
@@ -339,7 +349,7 @@ class Rbd(RESTController):
     @RESTController.Resource('POST')
     def copy(self, pool_name, image_name, dest_pool_name, dest_image_name,
              snapshot_name=None, obj_size=None, features=None, stripe_unit=None,
-             stripe_count=None, data_pool=None):
+             stripe_count=None, data_pool=None, configuration=None):
 
         def _src_copy(s_ioctx, s_img):
             def _copy(d_ioctx):
@@ -356,6 +366,8 @@ class Rbd(RESTController):
 
                 s_img.copy(d_ioctx, dest_image_name, feature_bitmask, l_order,
                            stripe_unit, stripe_count, data_pool)
+                RbdConfiguration(pool_ioctx=d_ioctx, image_name=dest_image_name).set_configuration(
+                    configuration)
 
             return _rbd_call(dest_pool_name, _copy)
 
@@ -386,6 +398,11 @@ class Rbd(RESTController):
         rbd_inst = rbd.RBD()
         return _rbd_call(pool_name, rbd_inst.trash_move, image_name, delay)
 
+    @RESTController.Resource()
+    @ReadPermission
+    def configuration(self, pool_name, image_name):
+        return RbdConfiguration(pool_name, image_name).list()
+
 
 @ApiController('/block/image/{pool_name}/{image_name}/snap', Scope.RBD_IMAGE)
 class RbdSnapshot(RESTController):
@@ -444,8 +461,11 @@ class RbdSnapshot(RESTController):
               'child_image_name': '{child_image_name}'}, 2.0)
     @RESTController.Resource('POST')
     def clone(self, pool_name, image_name, snapshot_name, child_pool_name,
-              child_image_name, obj_size=None, features=None,
-              stripe_unit=None, stripe_count=None, data_pool=None):
+              child_image_name, obj_size=None, features=None, stripe_unit=None, stripe_count=None,
+              data_pool=None, configuration=None):
+        """
+        Clones a snapshot to an image
+        """
 
         def _parent_clone(p_ioctx):
             def _clone(ioctx):
@@ -462,9 +482,12 @@ class RbdSnapshot(RESTController):
                                child_image_name, feature_bitmask, l_order,
                                stripe_unit, stripe_count, data_pool)
 
+                RbdConfiguration(pool_ioctx=ioctx, image_name=child_image_name).set_configuration(
+                    configuration)
+
             return _rbd_call(child_pool_name, _clone)
 
-        return _rbd_call(pool_name, _parent_clone)
+        _rbd_call(pool_name, _parent_clone)
 
 
 @ApiController('/block/image/trash', Scope.RBD_IMAGE)
index b21930172a7f3ffd8976e214b430fe30a0752d33..4c706d4ae62929af16181f4618df83d89bf7062f 100644 (file)
@@ -21,6 +21,8 @@ import { IscsiTargetIqnSettingsModalComponent } from './iscsi-target-iqn-setting
 import { IscsiTargetListComponent } from './iscsi-target-list/iscsi-target-list.component';
 import { IscsiComponent } from './iscsi/iscsi.component';
 import { MirroringModule } from './mirroring/mirroring.module';
+import { RbdConfigurationFormComponent } from './rbd-configuration-form/rbd-configuration-form.component';
+import { RbdConfigurationListComponent } from './rbd-configuration-list/rbd-configuration-list.component';
 import { RbdDetailsComponent } from './rbd-details/rbd-details.component';
 import { RbdFormComponent } from './rbd-form/rbd-form.component';
 import { RbdImagesComponent } from './rbd-images/rbd-images.component';
@@ -77,7 +79,10 @@ import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-tra
     IscsiTargetFormComponent,
     IscsiTargetImageSettingsModalComponent,
     IscsiTargetIqnSettingsModalComponent,
-    IscsiTargetDiscoveryModalComponent
-  ]
+    IscsiTargetDiscoveryModalComponent,
+    RbdConfigurationListComponent,
+    RbdConfigurationFormComponent
+  ],
+  exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
 })
 export class BlockModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html
new file mode 100644 (file)
index 0000000..63a5c88
--- /dev/null
@@ -0,0 +1,79 @@
+<fieldset #cfgFormGroup [formGroup]="form.get('configuration')">
+  <legend i18n>RBD Configuration</legend>
+
+  <div *ngFor="let section of rbdConfigurationService.sections">
+    <h3 class="page-header">
+      <span
+        (click)="toggleSectionVisibility(section.class)"
+        class="collapsible">{{ section.heading }} <i [ngClass]="{'fa-plus-circle': !sectionVisibility[section.class], 'fa-minus-circle': sectionVisibility[section.class]}" class="fa" aria-hidden="true"></i></span>
+    </h3>
+    <div class="{{ section.class }}" [hidden]="!sectionVisibility[section.class]">
+      <div
+        class="form-group"
+        *ngFor="let option of section.options"
+        [ngClass]="{'has-error': form.showError('configuration.' + option.name, cfgFormGroup)}">
+        <label
+          class="control-label col-sm-3"
+          [for]="option.name">{{ option.displayName }}<cd-helper>{{ option.description }}</cd-helper></label>
+
+        <div class="col-sm-9 {{ section.heading }}">
+          <div class="input-group">
+            <ng-container [ngSwitch]="option.type">
+              <ng-container *ngSwitchCase="configurationType.milliseconds">
+                <input
+                  [id]="option.name"
+                  [name]="option.name"
+                  [formControlName]="option.name"
+                  type="text"
+                  class="form-control"
+                  [ngDataReady]="ngDataReady"
+                  cdMilliseconds>
+              </ng-container>
+              <ng-container *ngSwitchCase="configurationType.bps">
+                <input
+                  [id]="option.name"
+                  [name]="option.name"
+                  [formControlName]="option.name"
+                  type="text"
+                  class="form-control"
+                  defaultUnit="b"
+                  [ngDataReady]="ngDataReady"
+                  cdDimlessBinaryPerSecond>
+              </ng-container>
+              <ng-container *ngSwitchCase="configurationType.iops">
+                <input
+                  [id]="option.name"
+                  [name]="option.name"
+                  [formControlName]="option.name"
+                  type="text"
+                  class="form-control"
+                  [ngDataReady]="ngDataReady"
+                  cdIops>
+              </ng-container>
+            </ng-container>
+            <span class="input-group-btn">
+              <button
+                class="btn btn-default"
+                type="button"
+                data-toggle="button"
+                [ngClass]="{'active': isDisabled(option.name)}"
+                tooltip="Remove the local configuration value. The parent configuration value will be inherited and used instead."
+                containerClass="tooltip-wide"
+                [delay]="1000"
+                i18n-tooltip
+                (click)="reset(option.name)">
+                <i class="fa fa-eraser"
+                   aria-hidden="true"></i>
+              </button>
+            </span>
+          </div>
+          <span
+            i18n
+            class="help-block"
+            *ngIf="form.showError('configuration.' + option.name, cfgFormGroup, 'min')">The mininum value is 0</span>
+        </div>
+      </div>
+    </div>
+  </div>
+
+</fieldset>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.scss
new file mode 100644 (file)
index 0000000..ba6460c
--- /dev/null
@@ -0,0 +1,4 @@
+.collapsible {
+  cursor: pointer;
+  user-select: none;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.spec.ts
new file mode 100644 (file)
index 0000000..dbce863
--- /dev/null
@@ -0,0 +1,305 @@
+import { EventEmitter } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+
+import { ComponentLoaderFactory } from 'ngx-bootstrap/component-loader';
+import { PositioningService } from 'ngx-bootstrap/positioning';
+import { TooltipConfig, TooltipModule } from 'ngx-bootstrap/tooltip';
+
+import { configureTestBed, FormHelper, i18nProviders } from '../../../../testing/unit-test-helper';
+import { DirectivesModule } from '../../../shared/directives/directives.module';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { RbdConfigurationSourceField } from '../../../shared/models/configuration';
+import { DimlessBinaryPerSecondPipe } from '../../../shared/pipes/dimless-binary-per-second.pipe';
+import { FormatterService } from '../../../shared/services/formatter.service';
+import { RbdConfigurationService } from '../../../shared/services/rbd-configuration.service';
+import { SharedModule } from '../../../shared/shared.module';
+import { RbdConfigurationFormComponent } from './rbd-configuration-form.component';
+
+describe('RbdConfigurationFormComponent', () => {
+  let component: RbdConfigurationFormComponent;
+  let fixture: ComponentFixture<RbdConfigurationFormComponent>;
+  let sections: any[];
+  let fh: FormHelper;
+
+  configureTestBed({
+    imports: [ReactiveFormsModule, TooltipModule, DirectivesModule, SharedModule],
+    declarations: [RbdConfigurationFormComponent],
+    providers: [
+      ComponentLoaderFactory,
+      PositioningService,
+      TooltipConfig,
+      RbdConfigurationService,
+      FormatterService,
+      DimlessBinaryPerSecondPipe,
+      i18nProviders
+    ]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RbdConfigurationFormComponent);
+    component = fixture.componentInstance;
+    component.form = new CdFormGroup({}, null);
+    fh = new FormHelper(component.form);
+    fixture.detectChanges();
+    sections = TestBed.get(RbdConfigurationService).sections;
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should create all form fields mentioned in RbdConfiguration::OPTIONS', () => {
+    /* Test form creation on a TypeScript level */
+    const actual = Object.keys((component.form.get('configuration') as CdFormGroup).controls);
+    const expected = sections
+      .map((section) => section.options)
+      .reduce((a, b) => a.concat(b))
+      .map((option) => option.name);
+    expect(actual).toEqual(expected);
+
+    /* Test form creation on a template level */
+    const controlDebugElements = fixture.debugElement.queryAll(By.css('input.form-control'));
+    expect(controlDebugElements.length).toBe(expected.length);
+    controlDebugElements.forEach((element) => expect(element.nativeElement).toBeTruthy());
+  });
+
+  it('should only contain values of changed controls if submitted', () => {
+    let values = {};
+    component.changes.subscribe((getDirtyValues: Function) => {
+      values = getDirtyValues();
+    });
+    fh.setValue('configuration.rbd_qos_bps_limit', 0, true);
+    fixture.detectChanges();
+
+    expect(values).toEqual({ rbd_qos_bps_limit: 0 });
+  });
+
+  describe('test loading of initial data for editing', () => {
+    beforeEach(() => {
+      component.initializeData = new EventEmitter<any>();
+      fixture.detectChanges();
+      component.ngOnInit();
+    });
+
+    it('should return dirty values without any units', () => {
+      let dirtyValues = {};
+      component.changes.subscribe((getDirtyValues) => {
+        dirtyValues = getDirtyValues();
+      });
+
+      fh.setValue('configuration.rbd_qos_bps_limit', 55, true);
+      fh.setValue('configuration.rbd_qos_iops_limit', 22, true);
+
+      expect(dirtyValues['rbd_qos_bps_limit']).toBe(55);
+      expect(dirtyValues['rbd_qos_iops_limit']).toBe(22);
+    });
+
+    it('should load initial data into forms', () => {
+      component.initializeData.emit({
+        initialData: [
+          {
+            name: 'rbd_qos_bps_limit',
+            value: 55,
+            source: 1
+          }
+        ],
+        sourceType: RbdConfigurationSourceField.pool
+      });
+
+      expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('55 B/s');
+    });
+
+    it('should not load initial data if the source is not the pool itself', () => {
+      component.initializeData.emit({
+        initialData: [
+          {
+            name: 'rbd_qos_bps_limit',
+            value: 55,
+            source: RbdConfigurationSourceField.image
+          },
+          {
+            name: 'rbd_qos_iops_limit',
+            value: 22,
+            source: RbdConfigurationSourceField.global
+          }
+        ],
+        sourceType: RbdConfigurationSourceField.pool
+      });
+
+      expect(component.form.getValue('configuration.rbd_qos_iops_limit')).toEqual('0 IOPS');
+      expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('0 B/s');
+    });
+
+    it('should not load initial data if the source is not the image itself', () => {
+      component.initializeData.emit({
+        initialData: [
+          {
+            name: 'rbd_qos_bps_limit',
+            value: 55,
+            source: RbdConfigurationSourceField.pool
+          },
+          {
+            name: 'rbd_qos_iops_limit',
+            value: 22,
+            source: RbdConfigurationSourceField.global
+          }
+        ],
+        sourceType: RbdConfigurationSourceField.image
+      });
+
+      expect(component.form.getValue('configuration.rbd_qos_iops_limit')).toEqual('0 IOPS');
+      expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('0 B/s');
+    });
+
+    it('should always have formatted results', () => {
+      component.initializeData.emit({
+        initialData: [
+          {
+            name: 'rbd_qos_bps_limit',
+            value: 55,
+            source: RbdConfigurationSourceField.image
+          },
+          {
+            name: 'rbd_qos_iops_limit',
+            value: 22,
+            source: RbdConfigurationSourceField.image
+          },
+          {
+            name: 'rbd_qos_read_bps_limit',
+            value: null, // incorrect type
+            source: RbdConfigurationSourceField.image
+          },
+          {
+            name: 'rbd_qos_read_bps_limit',
+            value: undefined, // incorrect type
+            source: RbdConfigurationSourceField.image
+          }
+        ],
+        sourceType: RbdConfigurationSourceField.image
+      });
+
+      expect(component.form.getValue('configuration.rbd_qos_iops_limit')).toEqual('22 IOPS');
+      expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('55 B/s');
+      expect(component.form.getValue('configuration.rbd_qos_read_bps_limit')).toEqual('0 B/s');
+      expect(component.form.getValue('configuration.rbd_qos_read_bps_limit')).toEqual('0 B/s');
+    });
+  });
+
+  it('should reset the corresponding form field correctly', () => {
+    const fieldName = 'rbd_qos_bps_limit';
+    const getValue = () => component.form.get(`configuration.${fieldName}`).value;
+
+    // Initialization
+    fh.setValue(`configuration.${fieldName}`, 418, true);
+    expect(getValue()).toBe(418);
+
+    // Reset
+    component.reset(fieldName);
+    expect(getValue()).toBe(null);
+
+    // Restore
+    component.reset(fieldName);
+    expect(getValue()).toBe(418);
+
+    // Reset
+    component.reset(fieldName);
+    expect(getValue()).toBe(null);
+
+    // Restore
+    component.reset(fieldName);
+    expect(getValue()).toBe(418);
+  });
+
+  describe('should verify that getDirtyValues() returns correctly', () => {
+    let data;
+
+    beforeEach(() => {
+      component.initializeData = new EventEmitter<any>();
+      fixture.detectChanges();
+      component.ngOnInit();
+      data = {
+        initialData: [
+          {
+            name: 'rbd_qos_bps_limit',
+            value: 0,
+            source: RbdConfigurationSourceField.image
+          },
+          {
+            name: 'rbd_qos_iops_limit',
+            value: 0,
+            source: RbdConfigurationSourceField.image
+          },
+          {
+            name: 'rbd_qos_read_bps_limit',
+            value: 0,
+            source: RbdConfigurationSourceField.image
+          },
+          {
+            name: 'rbd_qos_read_iops_limit',
+            value: 0,
+            source: RbdConfigurationSourceField.image
+          },
+          {
+            name: 'rbd_qos_read_iops_burst',
+            value: 0,
+            source: RbdConfigurationSourceField.image
+          },
+          {
+            name: 'rbd_qos_write_bps_burst',
+            value: undefined,
+            source: RbdConfigurationSourceField.global
+          },
+          {
+            name: 'rbd_qos_write_iops_burst',
+            value: null,
+            source: RbdConfigurationSourceField.global
+          }
+        ],
+        sourceType: RbdConfigurationSourceField.image
+      };
+      component.initializeData.emit(data);
+    });
+
+    it('should return an empty object', () => {
+      expect(component.getDirtyValues()).toEqual({});
+      expect(component.getDirtyValues(true, RbdConfigurationSourceField.image)).toEqual({});
+    });
+
+    it('should return dirty values', () => {
+      component.form.get('configuration.rbd_qos_write_bps_burst').markAsDirty();
+      expect(component.getDirtyValues()).toEqual({ rbd_qos_write_bps_burst: 0 });
+
+      component.form.get('configuration.rbd_qos_write_iops_burst').markAsDirty();
+      expect(component.getDirtyValues()).toEqual({
+        rbd_qos_write_iops_burst: 0,
+        rbd_qos_write_bps_burst: 0
+      });
+    });
+
+    it('should also return all local values if they do not contain their initial values', () => {
+      // Change value for all options
+      data.initialData = data.initialData.map((o) => {
+        o.value = 22;
+        return o;
+      });
+
+      // Mark some dirty
+      ['rbd_qos_read_iops_limit', 'rbd_qos_write_bps_burst'].forEach((option) => {
+        component.form.get(`configuration.${option}`).markAsDirty();
+      });
+
+      expect(component.getDirtyValues(true, RbdConfigurationSourceField.image)).toEqual({
+        rbd_qos_read_iops_limit: 0,
+        rbd_qos_write_bps_burst: 0
+      });
+    });
+
+    it('should throw an error if used incorrectly', () => {
+      expect(() => component.getDirtyValues(true)).toThrowError(
+        /^ProgrammingError: If local values shall be included/
+      );
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.ts
new file mode 100644 (file)
index 0000000..072ab6f
--- /dev/null
@@ -0,0 +1,152 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import {
+  RbdConfigurationEntry,
+  RbdConfigurationSourceField,
+  RbdConfigurationType
+} from '../../../shared/models/configuration';
+import { FormatterService } from '../../../shared/services/formatter.service';
+import { RbdConfigurationService } from '../../../shared/services/rbd-configuration.service';
+
+@Component({
+  selector: 'cd-rbd-configuration-form',
+  templateUrl: './rbd-configuration-form.component.html',
+  styleUrls: ['./rbd-configuration-form.component.scss']
+})
+export class RbdConfigurationFormComponent implements OnInit {
+  @Input()
+  form: CdFormGroup;
+  @Input()
+  initializeData: EventEmitter<{
+    initialData: RbdConfigurationEntry[];
+    sourceType: RbdConfigurationSourceField;
+  }>;
+  @Output()
+  changes = new EventEmitter<any>();
+  ngDataReady = new EventEmitter<any>();
+  initialData: RbdConfigurationEntry[];
+  configurationType = RbdConfigurationType;
+  sectionVisibility: { [key: string]: boolean } = {};
+
+  constructor(
+    public formatterService: FormatterService,
+    public rbdConfigurationService: RbdConfigurationService
+  ) {}
+
+  ngOnInit() {
+    const configFormGroup = this.createConfigurationFormGroup();
+    this.form.addControl('configuration', configFormGroup);
+
+    // Listen to changes and emit the values to the parent component
+    configFormGroup.valueChanges.subscribe(() => {
+      this.changes.emit(this.getDirtyValues.bind(this));
+    });
+
+    if (this.initializeData) {
+      this.initializeData.subscribe((data) => {
+        this.initialData = data.initialData;
+        const dataType = data.sourceType;
+
+        this.rbdConfigurationService.getWritableOptionFields().forEach((option) => {
+          const optionData = data.initialData.filter((entry) => entry.name === option.name).pop();
+          if (optionData && optionData['source'] === dataType) {
+            this.form.get(`configuration.${option.name}`).setValue(optionData['value']);
+          }
+        });
+        this.ngDataReady.emit();
+      });
+    }
+
+    this.rbdConfigurationService
+      .getWritableSections()
+      .forEach((section) => (this.sectionVisibility[section.class] = false));
+  }
+
+  getDirtyValues(includeLocalValues = false, localFieldType?: RbdConfigurationSourceField) {
+    if (includeLocalValues && !localFieldType) {
+      const msg =
+        'ProgrammingError: If local values shall be included, a proper localFieldType argument has to be provided, too';
+      throw new Error(msg);
+    }
+    const result = {};
+
+    this.rbdConfigurationService.getWritableOptionFields().forEach((config) => {
+      const control = this.form.get('configuration').get(config.name);
+      const dirty = control.dirty;
+
+      if (this.initialData && this.initialData[config.name] === control.value) {
+        return; // Skip controls with initial data loaded
+      }
+
+      if (dirty || (includeLocalValues && control['source'] === localFieldType)) {
+        if (control.value === null) {
+          result[config.name] = control.value;
+        } else if (config.type === RbdConfigurationType.bps) {
+          result[config.name] = this.formatterService.toBytes(control.value);
+        } else if (config.type === RbdConfigurationType.milliseconds) {
+          result[config.name] = this.formatterService.toMilliseconds(control.value);
+        } else if (config.type === RbdConfigurationType.iops) {
+          result[config.name] = this.formatterService.toIops(control.value);
+        } else {
+          result[config.name] = control.value;
+        }
+      }
+    });
+
+    return result;
+  }
+
+  /**
+   * Dynamically create form controls.
+   */
+  private createConfigurationFormGroup() {
+    const configFormGroup = new CdFormGroup({});
+
+    this.rbdConfigurationService.getWritableOptionFields().forEach((c) => {
+      let control: FormControl;
+      if (
+        c.type === RbdConfigurationType.milliseconds ||
+        c.type === RbdConfigurationType.iops ||
+        c.type === RbdConfigurationType.bps
+      ) {
+        control = new FormControl(0, Validators.min(0));
+      } else {
+        throw new Error(
+          `Type ${c.type} is unknown, you may need to add it to RbdConfiguration class`
+        );
+      }
+      configFormGroup.addControl(c.name, control);
+    });
+
+    return configFormGroup;
+  }
+
+  /**
+   * Reset the value. The inherited value will be used instead.
+   */
+  reset(optionName: string) {
+    const formControl = this.form.get('configuration').get(optionName);
+    if (formControl.disabled) {
+      formControl.setValue(formControl['previousValue'] || 0);
+      formControl.enable();
+      if (!formControl['previousValue']) {
+        formControl.markAsPristine();
+      }
+    } else {
+      formControl['previousValue'] = formControl.value;
+      formControl.setValue(null);
+      formControl.markAsDirty();
+      formControl.disable();
+    }
+  }
+
+  isDisabled(optionName: string) {
+    return this.form.get('configuration').get(optionName).disabled;
+  }
+
+  toggleSectionVisibility(className) {
+    this.sectionVisibility[className] = !this.sectionVisibility[className];
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html
new file mode 100644 (file)
index 0000000..80d0b94
--- /dev/null
@@ -0,0 +1,25 @@
+<cd-table [data]="data"
+          [columns]="poolConfigurationColumns"
+          identifier="name">
+</cd-table>
+
+<ng-template #configurationSourceTpl
+             let-row="row"
+             let-value="value">
+  <div [ngSwitch]="+value">
+    <span *ngSwitchCase="sourceField.global" i18n>Global</span>
+    <strong *ngSwitchCase="sourceField.image" i18n>Image</strong>
+    <strong *ngSwitchCase="sourceField.pool" i18n>Pool</strong>
+  </div>
+</ng-template>
+
+<ng-template #configurationValueTpl
+             let-row="row"
+             let-value="value">
+  <div [ngSwitch]="row.type">
+    <span *ngSwitchCase="typeField.bps">{{ value | dimlessBinaryPerSecond }}</span>
+    <span *ngSwitchCase="typeField.milliseconds">{{ value | milliseconds }}</span>
+    <span *ngSwitchCase="typeField.iops">{{ value | iops }}</span>
+    <span *ngSwitchDefault>{{ value }}</span>
+  </div>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.spec.ts
new file mode 100644 (file)
index 0000000..72b0114
--- /dev/null
@@ -0,0 +1,67 @@
+import { SimpleChange } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgxDatatableModule } from '@swimlane/ngx-datatable';
+import { ChartsModule } from 'ng2-charts';
+import { AlertModule } from 'ngx-bootstrap/alert';
+
+import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import { ErrorPanelComponent } from '../../../shared/components/error-panel/error-panel.component';
+import { SparklineComponent } from '../../../shared/components/sparkline/sparkline.component';
+import { TableComponent } from '../../../shared/datatable/table/table.component';
+import { RbdConfigurationEntry } from '../../../shared/models/configuration';
+import { PipesModule } from '../../../shared/pipes/pipes.module';
+import { FormatterService } from '../../../shared/services/formatter.service';
+import { RbdConfigurationService } from '../../../shared/services/rbd-configuration.service';
+import { RbdConfigurationListComponent } from './rbd-configuration-list.component';
+
+describe('RbdConfigurationListComponent', () => {
+  let component: RbdConfigurationListComponent;
+  let fixture: ComponentFixture<RbdConfigurationListComponent>;
+
+  configureTestBed({
+    imports: [
+      FormsModule,
+      NgxDatatableModule,
+      RouterTestingModule,
+      AlertModule,
+      ChartsModule,
+      PipesModule
+    ],
+    declarations: [
+      RbdConfigurationListComponent,
+      TableComponent,
+      ErrorPanelComponent,
+      SparklineComponent
+    ],
+    providers: [FormatterService, RbdConfigurationService, i18nProviders]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RbdConfigurationListComponent);
+    component = fixture.componentInstance;
+    component.data = [];
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('filters options out which are not defined in RbdConfigurationService', () => {
+    const fakeOption = { name: 'foo', source: 0, value: '50' } as RbdConfigurationEntry;
+    const realOption = {
+      name: 'rbd_qos_read_iops_burst',
+      source: 0,
+      value: '50'
+    } as RbdConfigurationEntry;
+
+    component.data = [fakeOption, realOption];
+    component.ngOnChanges({ name: new SimpleChange(null, null, null) });
+
+    expect(component.data.length).toBe(1);
+    expect(component.data.pop()).toBe(realOption);
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts
new file mode 100644 (file)
index 0000000..55389fd
--- /dev/null
@@ -0,0 +1,66 @@
+import {
+  Component,
+  Input,
+  OnChanges,
+  OnInit,
+  SimpleChanges,
+  TemplateRef,
+  ViewChild
+} from '@angular/core';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import {
+  RbdConfigurationEntry,
+  RbdConfigurationSourceField,
+  RbdConfigurationType
+} from '../../../shared/models/configuration';
+import { FormatterService } from '../../../shared/services/formatter.service';
+import { RbdConfigurationService } from '../../../shared/services/rbd-configuration.service';
+
+@Component({
+  selector: 'cd-rbd-configuration-table',
+  templateUrl: './rbd-configuration-list.component.html',
+  styleUrls: ['./rbd-configuration-list.component.scss']
+})
+export class RbdConfigurationListComponent implements OnInit, OnChanges {
+  @Input()
+  data: RbdConfigurationEntry[];
+  poolConfigurationColumns: CdTableColumn[];
+  @ViewChild('configurationSourceTpl')
+  configurationSourceTpl: TemplateRef<any>;
+  @ViewChild('configurationValueTpl')
+  configurationValueTpl: TemplateRef<any>;
+
+  readonly sourceField = RbdConfigurationSourceField;
+  readonly typeField = RbdConfigurationType;
+
+  constructor(
+    public formatterService: FormatterService,
+    private rbdConfigurationService: RbdConfigurationService,
+    private i18n: I18n
+  ) {}
+
+  ngOnInit() {
+    this.poolConfigurationColumns = [
+      { prop: 'displayName', name: this.i18n('Name') },
+      { prop: 'description', name: this.i18n('Description') },
+      { prop: 'name', name: this.i18n('Key') },
+      { prop: 'source', name: this.i18n('Source'), cellTemplate: this.configurationSourceTpl },
+      { prop: 'value', name: this.i18n('Value'), cellTemplate: this.configurationValueTpl }
+    ];
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (!this.data) {
+      return;
+    }
+    // Filter settings out which are not listed in RbdConfigurationService
+    this.data = this.data.filter((row) =>
+      this.rbdConfigurationService
+        .getOptionFields()
+        .map((o) => o.name)
+        .includes(row.name)
+    );
+  }
+}
index 28546d0d048567bd6379585ee5eb7328c6d7f063..889cab1f9a4bda293e97751b11310c473b000410 100644 (file)
                           [poolName]="selectedItem.pool_name"
                           [rbdName]="selectedItem.name"></cd-rbd-snapshot-list>
   </tab>
+  <tab i18n-heading
+       heading="Configuration">
+    <cd-rbd-configuration-table [data]="selectedItem['configuration']"></cd-rbd-configuration-table>
+  </tab>
 </tabset>
+
+<ng-template
+  #poolConfigurationSourceTpl
+  let-row="row"
+  let-value="value">
+  <ng-container *ngIf="+value; else global">
+    <strong i18n i18n-tooltip tooltip="This setting overrides the global value">Image</strong>
+  </ng-container>
+  <ng-template #global><span i18n i18n-tooltip tooltip="This is the global value. No value for this option has been set for this image.">Global</span></ng-template>
+</ng-template>
+
index ac83e952cebe99f0c1a119c7b3dbccdd360462d9..44646595e812bdb9df4eff386d5a399b42596b66 100644 (file)
@@ -6,6 +6,7 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip';
 
 import { configureTestBed } from '../../../../testing/unit-test-helper';
 import { SharedModule } from '../../../shared/shared.module';
+import { RbdConfigurationListComponent } from '../rbd-configuration-list/rbd-configuration-list.component';
 import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component';
 import { RbdDetailsComponent } from './rbd-details.component';
 
@@ -14,7 +15,7 @@ describe('RbdDetailsComponent', () => {
   let fixture: ComponentFixture<RbdDetailsComponent>;
 
   configureTestBed({
-    declarations: [RbdDetailsComponent, RbdSnapshotListComponent],
+    declarations: [RbdDetailsComponent, RbdSnapshotListComponent, RbdConfigurationListComponent],
     imports: [SharedModule, TabsModule.forRoot(), TooltipModule.forRoot(), RouterTestingModule]
   });
 
index 702416b96d78b6b491508f2b2a40cb258ae4680f..a579a1d315e6ff6d8f3558989c93e5c05206cfda 100644 (file)
@@ -1,6 +1,7 @@
-import { Component, Input, OnChanges } from '@angular/core';
+import { Component, Input, OnChanges, TemplateRef, ViewChild } from '@angular/core';
 
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { RbdFormModel } from '../rbd-form/rbd-form.model';
 
 @Component({
   selector: 'cd-rbd-details',
@@ -10,7 +11,11 @@ import { CdTableSelection } from '../../../shared/models/cd-table-selection';
 export class RbdDetailsComponent implements OnChanges {
   @Input()
   selection: CdTableSelection;
-  selectedItem: any;
+  selectedItem: RbdFormModel;
+  @Input()
+  images: any;
+  @ViewChild('poolConfigurationSourceTpl')
+  poolConfigurationSourceTpl: TemplateRef<any>;
 
   constructor() {}
 
index 826d4cc3fa909a04015e105fd71f88c51e6d73cf..0c18352d6574751a979a9e0ed5ce8be9a60a7c9b 100644 (file)
@@ -1,3 +1,5 @@
+import { RbdConfigurationEntry } from '../../../shared/models/configuration';
+
 export class RbdFormCloneRequestModel {
   child_pool_name: string;
   child_image_name: string;
@@ -6,4 +8,5 @@ export class RbdFormCloneRequestModel {
   stripe_unit: number;
   stripe_count: number;
   data_pool: string;
+  configuration?: RbdConfigurationEntry[];
 }
index 4553dc81c1fb6002bf1df7e051a76fb8bb6cccad..c1b290dca283c22ac7ad3bbfa23c67ed7644caf5 100644 (file)
@@ -1,3 +1,5 @@
+import { RbdConfigurationEntry } from '../../../shared/models/configuration';
+
 export class RbdFormCopyRequestModel {
   dest_pool_name: string;
   dest_image_name: string;
@@ -7,4 +9,5 @@ export class RbdFormCopyRequestModel {
   stripe_unit: number;
   stripe_count: number;
   data_pool: string;
+  configuration: RbdConfigurationEntry[];
 }
index 39495630a4c53033460337e724e9c4ed0364d79e..37997e22d7d52a46c0b7d84b71ea5d37be771d33 100644 (file)
@@ -1,5 +1,8 @@
+import { RbdConfigurationEntry } from '../../../shared/models/configuration';
+
 export class RbdFormEditRequestModel {
   name: string;
   size: number;
   features: Array<string> = [];
+  configuration: RbdConfigurationEntry[];
 }
index ec9729bfe293b724c7883e03a0964f2157c66f43..5a0ef4ef0cd9d4dcc3d50300f8aa3581e300df4c 100644 (file)
@@ -7,7 +7,9 @@
     <div class="panel panel-default">
       <div class="panel-heading">
         <h3 class="panel-title">
-          <span i18n>{mode, select, editing {Edit} cloning {Clone} copying {Copy} other {Add}}</span> RBD
+          <span
+            i18n>{mode, select, editing {Edit} cloning {Clone} copying {Copy} other {Add}}</span>
+          RBD
         </h3>
       </div>
       <div class="panel-body">
                i18n>Advanced...</a>
           </div>
         </div>
-        <div *ngIf="advancedEnabled">
+        <div [hidden]="!advancedEnabled">
 
           <h2 i18n
               class="page-header">Advanced</h2>
 
-          <!-- Object Size -->
-          <div class="form-group"
-               [ngClass]="{'has-error': rbdForm.showError('obj_size', formDir)}">
-            <label i18n
-                   class="control-label col-sm-3"
-                   for="size">Object size</label>
-            <div class="col-sm-9">
-              <select id="obj_size"
-                      name="obj_size"
-                      class="form-control"
-                      formControlName="obj_size">
-                <option *ngFor="let objectSize of objectSizes"
-                        [value]="objectSize">{{ objectSize }}</option>
-              </select>
+          <div class="section">
+            <h3 class="page-header" i18n>Striping</h3>
+
+            <!-- Object Size -->
+            <div class="form-group"
+                 [ngClass]="{'has-error': rbdForm.showError('obj_size', formDir)}">
+              <label i18n
+                     class="control-label col-sm-3"
+                     for="size">Object size</label>
+              <div class="col-sm-9">
+                <select id="obj_size"
+                        name="obj_size"
+                        class="form-control"
+                        formControlName="obj_size">
+                  <option *ngFor="let objectSize of objectSizes"
+                          [value]="objectSize">{{ objectSize }}</option>
+                </select>
+              </div>
             </div>
-          </div>
 
-          <!-- Strippe Unit -->
-          <div class="form-group"
-               [ngClass]="{'has-error': rbdForm.showError('stripingUnit', formDir)}">
-            <label class="control-label col-sm-3"
-                   for="stripingUnit">
-              <span i18n>Stripe unit</span>
-              <span class="required"
-                    *ngIf="rbdForm.getValue('stripingCount')">
+            <!-- Stripe Unit -->
+            <div class="form-group"
+                 [ngClass]="{'has-error': rbdForm.showError('stripingUnit', formDir)}">
+              <label class="control-label col-sm-3"
+                     for="stripingUnit">
+                <span i18n>Stripe unit</span>
+                <span class="required"
+                      *ngIf="rbdForm.getValue('stripingCount')">
               </span>
-            </label>
-            <div class="col-sm-9">
-              <select id="stripingUnit"
-                      name="stripingUnit"
-                      class="form-control"
-                      formControlName="stripingUnit">
-                <option i18n
-                        [ngValue]="null">-- Select stripe unit --</option>
-                <option *ngFor="let objectSize of objectSizes"
-                        [value]="objectSize">{{ objectSize }}</option>
-              </select>
-              <span class="help-block"
-                    *ngIf="rbdForm.showError('stripingUnit', formDir, 'required')"
-                    i18n>This field is required because stripe count is defined!</span>
-              <span class="help-block"
-                    *ngIf="rbdForm.showError('stripingUnit', formDir, 'invalidStripingUnit')"
-                    i18n>Stripe unit is greater than object size.</span>
+              </label>
+              <div class="col-sm-9">
+                <select id="stripingUnit"
+                        name="stripingUnit"
+                        class="form-control"
+                        formControlName="stripingUnit">
+                  <option i18n
+                          [ngValue]="null">-- Select stripe unit --</option>
+                  <option *ngFor="let objectSize of objectSizes"
+                          [value]="objectSize">{{ objectSize }}</option>
+                </select>
+                <span class="help-block"
+                      *ngIf="rbdForm.showError('stripingUnit', formDir, 'required')"
+                      i18n>This field is required because stripe count is defined!</span>
+                <span class="help-block"
+                      *ngIf="rbdForm.showError('stripingUnit', formDir, 'invalidStripingUnit')"
+                      i18n>Stripe unit is greater than object size.</span>
+              </div>
             </div>
-          </div>
 
-          <!-- Strippe Count -->
-          <div class="form-group"
-               [ngClass]="{'has-error': rbdForm.showError('stripingCount', formDir)}">
-            <label class="control-label col-sm-3"
-                   for="stripingCount">
-              <span i18n>Stripe count</span>
-              <span class="required"
-                    *ngIf="rbdForm.getValue('stripingUnit')">
+            <!-- Stripe Count -->
+            <div class="form-group"
+                 [ngClass]="{'has-error': rbdForm.showError('stripingCount', formDir)}">
+              <label class="control-label col-sm-3"
+                     for="stripingCount">
+                <span i18n>Stripe count</span>
+                <span class="required"
+                      *ngIf="rbdForm.getValue('stripingUnit')">
               </span>
-            </label>
-            <div class="col-sm-9">
-              <input id="stripingCount"
-                     name="stripingCount"
-                     formControlName="stripingCount"
-                     class="form-control"
-                     type="number">
-              <span class="help-block"
-                    *ngIf="rbdForm.showError('stripingCount', formDir, 'required')"
-                    i18n>This field is required because stripe unit is defined!</span>
-              <span class="help-block"
-                    *ngIf="rbdForm.showError('stripingCount', formDir, 'min')"
-                    i18n>Stripe count must be greater than 0.</span>
+              </label>
+              <div class="col-sm-9">
+                <input id="stripingCount"
+                       name="stripingCount"
+                       formControlName="stripingCount"
+                       class="form-control"
+                       type="number">
+                <span class="help-block"
+                      *ngIf="rbdForm.showError('stripingCount', formDir, 'required')"
+                      i18n>This field is required because stripe unit is defined!</span>
+                <span class="help-block"
+                      *ngIf="rbdForm.showError('stripingCount', formDir, 'min')"
+                      i18n>Stripe count must be greater than 0.</span>
+              </div>
             </div>
           </div>
 
+          <div class="section">
+            <cd-rbd-configuration-form [form]="rbdForm"
+                                       [initializeData]="initializeConfigData"
+                                       (changes)="getDirtyConfigurationValues = $event"></cd-rbd-configuration-form>
+          </div>
+
         </div>
 
       </div>
           <cd-submit-button [form]="formDir"
                             type="button"
                             (submitAction)="submit()">
-            <span i18n>{mode, select, editing {Update} cloning {Clone} copying {Copy} other {Create}}</span> RBD
+            <span i18n>{mode, select, editing {Update} cloning {Clone} copying {Copy} other {Create}} RBD</span>
           </cd-submit-button>
           <button type="button"
                   class="btn btn-sm btn-default"
index 06dc9fb5e14bc70b07afbf8969ce163c453db18e..9d15d10cfe69f95b9259deda36f8304f7f316403 100644 (file)
@@ -3,13 +3,16 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { ReactiveFormsModule } from '@angular/forms';
 import { ActivatedRoute } from '@angular/router';
 import { RouterTestingModule } from '@angular/router/testing';
+import { TooltipModule } from 'ngx-bootstrap/tooltip';
 
 import { ToastModule } from 'ng2-toastr';
 
+import { By } from '@angular/platform-browser';
 import { ActivatedRouteStub } from '../../../../testing/activated-route-stub';
 import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
 import { RbdService } from '../../../shared/api/rbd.service';
 import { SharedModule } from '../../../shared/shared.module';
+import { RbdConfigurationFormComponent } from '../rbd-configuration-form/rbd-configuration-form.component';
 import { RbdFormMode } from './rbd-form-mode.enum';
 import { RbdFormComponent } from './rbd-form.component';
 
@@ -24,9 +27,10 @@ describe('RbdFormComponent', () => {
       ReactiveFormsModule,
       RouterTestingModule,
       ToastModule.forRoot(),
-      SharedModule
+      SharedModule,
+      TooltipModule
     ],
-    declarations: [RbdFormComponent],
+    declarations: [RbdFormComponent, RbdConfigurationFormComponent],
     providers: [
       {
         provide: ActivatedRoute,
@@ -70,4 +74,14 @@ describe('RbdFormComponent', () => {
       expect(component.snapName).toBe('baz/baz');
     });
   });
+
+  describe('test image configuration component', () => {
+    it('is visible', () => {
+      fixture.detectChanges();
+      expect(
+        fixture.debugElement.query(By.css('cd-rbd-configuration-form')).nativeElement.parentElement
+          .hidden
+      ).toBe(false);
+    });
+  });
 });
index 911456de88ba3d20b8a86ab43408c42a40fec095..9abc9d73a78eefbf25f9965de096a8288fdc41a3 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, EventEmitter, OnInit } from '@angular/core';
 import { FormControl, ValidatorFn, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 
@@ -9,6 +9,10 @@ import { Observable } from 'rxjs';
 import { PoolService } from '../../../shared/api/pool.service';
 import { RbdService } from '../../../shared/api/rbd.service';
 import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import {
+  RbdConfigurationEntry,
+  RbdConfigurationSourceField
+} from '../../../shared/models/configuration';
 import { FinishedTask } from '../../../shared/models/finished-task';
 import { Permission } from '../../../shared/models/permissions';
 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
@@ -37,6 +41,10 @@ export class RbdFormComponent implements OnInit {
   objectMapFormControl: FormControl;
   journalingFormControl: FormControl;
   fastDiffFormControl: FormControl;
+  getDirtyConfigurationValues: (
+    includeLocalField?: boolean,
+    localField?: RbdConfigurationSourceField
+  ) => RbdConfigurationEntry[];
 
   pools: Array<string> = null;
   allPools: Array<string> = null;
@@ -44,6 +52,10 @@ export class RbdFormComponent implements OnInit {
   allDataPools: Array<string> = null;
   features: any;
   featuresList = [];
+  initializeConfigData = new EventEmitter<{
+    initialData: RbdConfigurationEntry[];
+    sourceType: RbdConfigurationSourceField;
+  }>();
 
   pool: string;
 
@@ -430,6 +442,12 @@ export class RbdFormComponent implements OnInit {
       .get('stripingUnit')
       .setValue(this.dimlessBinaryPipe.transform(response.stripe_unit));
     this.rbdForm.get('stripingCount').setValue(response.stripe_count);
+
+    /* Configuration */
+    this.initializeConfigData.emit({
+      initialData: this.response.configuration,
+      sourceType: RbdConfigurationSourceField.image
+    });
   }
 
   createRequest() {
@@ -443,9 +461,15 @@ export class RbdFormComponent implements OnInit {
         request.features.push(feature.key);
       }
     });
+
+    /* Striping */
     request.stripe_unit = this.formatter.toBytes(this.rbdForm.getValue('stripingUnit'));
     request.stripe_count = this.rbdForm.getValue('stripingCount');
     request.data_pool = this.rbdForm.getValue('dataPool');
+
+    /* Configuration */
+    request.configuration = this.getDirtyConfigurationValues();
+
     return request;
   }
 
@@ -469,6 +493,9 @@ export class RbdFormComponent implements OnInit {
         request.features.push(feature.key);
       }
     });
+
+    request.configuration = this.getDirtyConfigurationValues();
+
     return request;
   }
 
@@ -482,9 +509,18 @@ export class RbdFormComponent implements OnInit {
         request.features.push(feature.key);
       }
     });
+
+    /* Striping */
     request.stripe_unit = this.formatter.toBytes(this.rbdForm.getValue('stripingUnit'));
     request.stripe_count = this.rbdForm.getValue('stripingCount');
     request.data_pool = this.rbdForm.getValue('dataPool');
+
+    /* Configuration */
+    request.configuration = this.getDirtyConfigurationValues(
+      true,
+      RbdConfigurationSourceField.image
+    );
+
     return request;
   }
 
@@ -530,14 +566,24 @@ export class RbdFormComponent implements OnInit {
         request.features.push(feature.key);
       }
     });
+
+    /* Striping */
     request.stripe_unit = this.formatter.toBytes(this.rbdForm.getValue('stripingUnit'));
     request.stripe_count = this.rbdForm.getValue('stripingCount');
     request.data_pool = this.rbdForm.getValue('dataPool');
+
+    /* Configuration */
+    request.configuration = this.getDirtyConfigurationValues(
+      true,
+      RbdConfigurationSourceField.image
+    );
+
     return request;
   }
 
   copyAction(): Observable<any> {
     const request = this.copyRequest();
+
     return this.taskWrapper.wrapTaskAroundCall({
       task: new FinishedTask('rbd/copy', {
         src_pool_name: this.response.pool_name,
@@ -551,6 +597,7 @@ export class RbdFormComponent implements OnInit {
 
   submit() {
     let action: Observable<any>;
+
     if (this.mode === this.rbdFormMode.editing) {
       action = this.editAction();
     } else if (this.mode === this.rbdFormMode.cloning) {
@@ -560,6 +607,7 @@ export class RbdFormComponent implements OnInit {
     } else {
       action = this.createAction();
     }
+
     action.subscribe(
       undefined,
       () => this.rbdForm.setErrors({ cdSubmitButton: true }),
index 014b82716d3a7fbbf91a17a543250f9aca467d67..9511a00d003022dcb2ca2d502f7fb12dc19ec6da 100644 (file)
@@ -1,9 +1,16 @@
+import { RbdConfigurationEntry } from '../../../shared/models/configuration';
+
 export class RbdFormModel {
   name: string;
   pool_name: string;
   data_pool: string;
   size: number;
+
+  /* Striping */
   obj_size: number;
   stripe_unit: number;
   stripe_count: number;
+
+  /* Configuration */
+  configuration: RbdConfigurationEntry[];
 }
index 7ea4874507232cae290621bd698b5b4a4a535191..8faade1e5ded5af91c89e1067ece232c57fb701e 100644 (file)
@@ -9,6 +9,7 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip';
 import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
 import { TaskListService } from '../../../shared/services/task-list.service';
 import { SharedModule } from '../../../shared/shared.module';
+import { RbdConfigurationListComponent } from '../rbd-configuration-list/rbd-configuration-list.component';
 import { RbdDetailsComponent } from '../rbd-details/rbd-details.component';
 import { RbdListComponent } from '../rbd-list/rbd-list.component';
 import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component';
@@ -25,7 +26,8 @@ describe('RbdImagesComponent', () => {
       RbdImagesComponent,
       RbdListComponent,
       RbdSnapshotListComponent,
-      RbdTrashListComponent
+      RbdTrashListComponent,
+      RbdConfigurationListComponent
     ],
     imports: [
       HttpClientTestingModule,
index e9631eabdf7720a3d9933840b7ae14f6db6a76be..93cd64b26d47feba8af180800805758de5efd4b0 100644 (file)
@@ -23,6 +23,7 @@ import { ExecutingTask } from '../../../shared/models/executing-task';
 import { SummaryService } from '../../../shared/services/summary.service';
 import { TaskListService } from '../../../shared/services/task-list.service';
 import { SharedModule } from '../../../shared/shared.module';
+import { RbdConfigurationListComponent } from '../rbd-configuration-list/rbd-configuration-list.component';
 import { RbdDetailsComponent } from '../rbd-details/rbd-details.component';
 import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component';
 import { RbdListComponent } from './rbd-list.component';
@@ -50,7 +51,12 @@ describe('RbdListComponent', () => {
       RouterTestingModule,
       HttpClientTestingModule
     ],
-    declarations: [RbdListComponent, RbdDetailsComponent, RbdSnapshotListComponent],
+    declarations: [
+      RbdListComponent,
+      RbdDetailsComponent,
+      RbdSnapshotListComponent,
+      RbdConfigurationListComponent
+    ],
     providers: [TaskListService, i18nProviders]
   });
 
index 8ed4ebf472b50222984bf1e7ec9572183700b1ec..7c1b1b5f9e95170aa477e70b2f0d38ee2446d14c 100644 (file)
@@ -1,5 +1,5 @@
 <tabset #tabsetChild
-        cdTableDetail 
+        cdTableDetail
         *ngIf="selection.hasSingleSelection">
   <tab i18n-heading
        heading="Details">
                 grafanaStyle="one">
     </cd-grafana>
   </tab>
+  <tab *ngIf="selection.first().type === 'replicated'"
+       i18n-heading
+       heading="Configuration">
+    <cd-rbd-configuration-table [data]="selectedPoolConfiguration"></cd-rbd-configuration-table>
+  </tab>
   <tab i18n-heading
        *ngIf="selection.first()['tiers'].length > 0"
        heading="Cache Tiers Details">
index 260e1b3c399469fd6eaccd57d87a6112684427c8..d6e5ae651036b854bdffd1dcf2efe611bdbdd724 100644 (file)
@@ -1,10 +1,12 @@
-import { Component, Input, ViewChild } from '@angular/core';
+import { Component, Input, OnChanges, ViewChild } from '@angular/core';
 
 import { I18n } from '@ngx-translate/i18n-polyfill';
 import { TabsetComponent } from 'ngx-bootstrap/tabs';
 
+import { PoolService } from '../../../shared/api/pool.service';
 import { CdTableColumn } from '../../../shared/models/cd-table-column';
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { RbdConfigurationEntry } from '../../../shared/models/configuration';
 import { Permissions } from '../../../shared/models/permissions';
 
 @Component({
@@ -12,7 +14,7 @@ import { Permissions } from '../../../shared/models/permissions';
   templateUrl: './pool-details.component.html',
   styleUrls: ['./pool-details.component.scss']
 })
-export class PoolDetailsComponent {
+export class PoolDetailsComponent implements OnChanges {
   cacheTierColumns: Array<CdTableColumn> = [];
 
   @Input()
@@ -23,8 +25,9 @@ export class PoolDetailsComponent {
   cacheTiers: any[];
   @ViewChild(TabsetComponent)
   tabsetChild: TabsetComponent;
+  selectedPoolConfiguration: RbdConfigurationEntry[];
 
-  constructor(private i18n: I18n) {
+  constructor(private i18n: I18n, private poolService: PoolService) {
     this.cacheTierColumns = [
       {
         prop: 'pool_name',
@@ -58,4 +61,12 @@ export class PoolDetailsComponent {
       }
     ];
   }
+
+  ngOnChanges() {
+    if (this.selection.hasSingleSelection) {
+      this.poolService.getConfiguration(this.selection.first().pool_name).subscribe((poolConf) => {
+        this.selectedPoolConfiguration = poolConf;
+      });
+    }
+  }
 }
index 6a15073edb4cb0c4e587662efade6e602a67b77e..adedeec6061ebf72618152b284d576b0e5edd748 100644 (file)
             </div>
           </div>
 
-          <!-- Applications -->
-          <div class="form-group">
-            <label i18n
-                   class="col-sm-3 control-label"
-                   for="applications">Applications</label>
-            <div class="col-sm-9">
-              <span class="form-control no-border full-height">
-                <cd-select-badges id="applications"
-                                  [customBadges]="true"
-                                  [customBadgeValidators]="data.applications.validators"
-                                  [messages]="data.applications.messages"
-                                  [data]="data.applications.selected"
-                                  [options]="data.applications.available"
-                                  [selectionLimit]="4">
-                </cd-select-badges>
-              </span>
-            </div>
+        </div>
+        <!-- Applications -->
+        <div class="form-group">
+          <label i18n
+                 class="col-sm-3 control-label"
+                 for="applications">Applications</label>
+          <div class="col-sm-9">
+            <span class="form-control no-border full-height">
+              <cd-select-badges id="applications"
+                                [customBadges]="true"
+                                [customBadgeValidators]="data.applications.validators"
+                                [messages]="data.applications.messages"
+                                [data]="data.applications.selected"
+                                [options]="data.applications.available"
+                                [selectionLimit]="4">
+              </cd-select-badges>
+            </span>
           </div>
+        </div>
 
           <!-- Compression -->
           <div *ngIf="info.is_all_bluestore"
             </div>
           </div>
 
+        <!-- Pool configuration -->
+        <div [hidden]="form.get('poolType').value !== 'replicated' || data.applications.selected.indexOf('rbd') === -1">
+          <cd-rbd-configuration-form [form]="form"
+                                     [initializeData]="initializeConfigData"
+                                     (changes)="currentConfigurationValues = $event()">
+          </cd-rbd-configuration-form>
         </div>
       </div>
+    </div>
 
-      <div class="panel-footer">
-        <div class="button-group text-right">
-          <cd-submit-button [form]="formDir"
-                            type="button"
-                            (submitAction)="submit()">
-            <span i18n>{editing, select, 1 {Edit} other {Create}} pool</span>
-          </cd-submit-button>
-          <button i18n
-                  type="button"
-                  class="btn btn-sm btn-default"
-                  routerLink="/pool">Back</button>
-        </div>
+    <div class="panel-footer">
+      <div class="button-group text-right">
+        <cd-submit-button [form]="formDir"
+                          type="button"
+                          (submitAction)="submit()">
+          <span i18n>{editing, select, 1 {Edit} other {Create}} pool</span>
+        </cd-submit-button>
+        <button i18n
+                type="button"
+                class="btn btn-sm btn-default"
+                routerLink="/pool">Back</button>
       </div>
     </div>
   </form>
index b672d371c3f70f77a70f11b3aa2f90dd06e40cfa..eab7387b9963e8c69a84ba755d7f098f043663de 100644 (file)
@@ -83,7 +83,11 @@ describe('PoolFormComponent', () => {
     return rule;
   };
 
-  const testSubmit = (pool: any, taskName: string, poolServiceMethod: 'create' | 'update') => {
+  const expectValidSubmit = (
+    pool: any,
+    taskName: string,
+    poolServiceMethod: 'create' | 'update'
+  ) => {
     spyOn(poolService, poolServiceMethod).and.stub();
     const taskWrapper = TestBed.get(TaskWrapperService);
     spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
@@ -840,7 +844,7 @@ describe('PoolFormComponent', () => {
       });
     };
     const testCreate = (pool) => {
-      testSubmit(pool, 'pool/create', 'create');
+      expectValidSubmit(pool, 'pool/create', 'create');
     };
 
     beforeEach(() => {
@@ -893,6 +897,27 @@ describe('PoolFormComponent', () => {
           flags: ['ec_overwrites']
         });
       });
+
+      it('with rbd qos settings', () => {
+        setMultipleValues({
+          name: 'replicatedRbdQos',
+          poolType: 'replicated',
+          size: 2,
+          pgNum: 32
+        });
+        component.currentConfigurationValues = {
+          rbd_qos_bps_limit: 55
+        };
+        testCreate({
+          pool: 'replicatedRbdQos',
+          pool_type: 'replicated',
+          size: 2,
+          pg_num: 32,
+          configuration: {
+            rbd_qos_bps_limit: 55
+          }
+        });
+      });
     });
 
     describe('replicated coded pool', () => {
@@ -974,6 +999,7 @@ describe('PoolFormComponent', () => {
       pool.options.compression_required_ratio = 0.8;
       pool.flags_names = 'someFlag1,someFlag2';
       pool.application_metadata = ['rbd', 'rgw'];
+
       createCrushRule({ name: 'someRule' });
       spyOn(poolService, 'get').and.callFake(() => of(pool));
     });
@@ -1044,7 +1070,7 @@ describe('PoolFormComponent', () => {
         it(`always provides the application metadata array with submit even if it's empty`, () => {
           expect(form.get('mode').dirty).toBe(false);
           component.data.applications.selected = [];
-          testSubmit(
+          expectValidSubmit(
             {
               application_metadata: [],
               pool: 'somePoolName'
@@ -1058,7 +1084,7 @@ describe('PoolFormComponent', () => {
           formHelper.setValue('minBlobSize', '').markAsDirty();
           formHelper.setValue('maxBlobSize', '').markAsDirty();
           formHelper.setValue('ratio', '').markAsDirty();
-          testSubmit(
+          expectValidSubmit(
             {
               application_metadata: ['rbd', 'rgw'],
               compression_max_blob_size: 0,
@@ -1073,7 +1099,7 @@ describe('PoolFormComponent', () => {
 
         it(`will unset mode not used anymore`, () => {
           formHelper.setValue('mode', 'none').markAsDirty();
-          testSubmit(
+          expectValidSubmit(
             {
               application_metadata: ['rbd', 'rgw'],
               compression_mode: 'unset',
@@ -1086,4 +1112,29 @@ describe('PoolFormComponent', () => {
       });
     });
   });
+
+  describe('test pool configuration component', () => {
+    it('is visible for replicated pools with rbd application', () => {
+      const poolType = component.form.get('poolType');
+      poolType.markAsDirty();
+      poolType.setValue('replicated');
+      component.data.applications.selected = ['rbd'];
+      fixture.detectChanges();
+      expect(
+        fixture.debugElement.query(By.css('cd-rbd-configuration-form')).nativeElement.parentElement
+          .hidden
+      ).toBe(false);
+    });
+
+    it('is invisible for erasure coded pools', () => {
+      const poolType = component.form.get('poolType');
+      poolType.markAsDirty();
+      poolType.setValue('erasure');
+      fixture.detectChanges();
+      expect(
+        fixture.debugElement.query(By.css('cd-rbd-configuration-form')).nativeElement.parentElement
+          .hidden
+      ).toBe(true);
+    });
+  });
 });
index 4a8424f350eba4e27e5b3b9fdbfd5d816f77ccb7..7e2bf84106bd0cb9d85dd6dc2a0078ff94341690 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, EventEmitter, OnInit } from '@angular/core';
 import { FormControl, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 
@@ -12,6 +12,10 @@ import { PoolService } from '../../../shared/api/pool.service';
 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { CdFormGroup } from '../../../shared/forms/cd-form-group';
 import { CdValidators } from '../../../shared/forms/cd-validators';
+import {
+  RbdConfigurationEntry,
+  RbdConfigurationSourceField
+} from '../../../shared/models/configuration';
 import { CrushRule } from '../../../shared/models/crush-rule';
 import { CrushStep } from '../../../shared/models/crush-step';
 import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile';
@@ -53,6 +57,11 @@ export class PoolFormComponent implements OnInit {
   current = {
     rules: []
   };
+  initializeConfigData = new EventEmitter<{
+    initialData: RbdConfigurationEntry[];
+    sourceType: RbdConfigurationSourceField;
+  }>();
+  currentConfigurationValues: { [configKey: string]: any } = {};
 
   constructor(
     private dimlessBinaryPipe: DimlessBinaryPipe,
@@ -177,6 +186,11 @@ export class PoolFormComponent implements OnInit {
   }
 
   private initEditFormData(pool: Pool) {
+    this.initializeConfigData.emit({
+      initialData: pool.configuration,
+      sourceType: RbdConfigurationSourceField.pool
+    });
+
     const dataMap = {
       name: pool.pool_name,
       poolType: pool.type,
@@ -569,10 +583,21 @@ export class PoolFormComponent implements OnInit {
         ]);
       }
     }
+
     const apps = this.data.applications.selected;
     if (apps.length > 0 || this.editing) {
       pool['application_metadata'] = apps;
     }
+
+    // Only collect configuration data for replicated pools, as QoS cannot be configured on EC
+    // pools. EC data pools inherit their settings from the corresponding replicated metadata pool.
+    if (
+      this.form.get('poolType').value === 'replicated' &&
+      !_.isEmpty(this.currentConfigurationValues)
+    ) {
+      pool['configuration'] = this.currentConfigurationValues;
+    }
+
     this.triggerApiTask(pool);
   }
 
index 209bf60bdd49277206347eaf8923feec397a12a7..117b7d26590cd1cb9766f042cb929500eee06062 100644 (file)
@@ -9,11 +9,12 @@ import { of } from 'rxjs';
 
 import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
 import { PoolService } from '../../../shared/api/pool.service';
-import { DeletionModalComponent } from '../../../shared/components/deletion-modal/deletion-modal.component';
+import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { ExecutingTask } from '../../../shared/models/executing-task';
 import { SummaryService } from '../../../shared/services/summary.service';
 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
 import { SharedModule } from '../../../shared/shared.module';
+import { RbdConfigurationListComponent } from '../../block/rbd-configuration-list/rbd-configuration-list.component';
 import { PgCategoryService } from '../../shared/pg-category.service';
 import { Pool } from '../pool';
 import { PoolDetailsComponent } from '../pool-details/pool-details.component';
@@ -39,7 +40,7 @@ describe('PoolListComponent', () => {
   };
 
   configureTestBed({
-    declarations: [PoolListComponent, PoolDetailsComponent],
+    declarations: [PoolListComponent, PoolDetailsComponent, RbdConfigurationListComponent],
     imports: [
       SharedModule,
       ToastModule.forRoot(),
@@ -72,7 +73,7 @@ describe('PoolListComponent', () => {
 
     const callDeletion = () => {
       component.deletePoolModal();
-      const deletion: DeletionModalComponent = component.modalRef.content;
+      const deletion: CriticalConfirmationModalComponent = component.modalRef.content;
       deletion.submitActionObservable();
     };
 
index a5545572e73ddd835b37196e79cbd184ce814ca2..fc1bbb0e5f89c345c37a46bd0644b7d9d5cb910d 100644 (file)
@@ -34,6 +34,9 @@ export class PoolListComponent implements OnInit {
   @ViewChild('poolUsageTpl')
   poolUsageTpl: TemplateRef<any>;
 
+  @ViewChild('poolConfigurationSourceTpl')
+  poolConfigurationSourceTpl: TemplateRef<any>;
+
   pools: Pool[] = [];
   columns: CdTableColumn[];
   selection = new CdTableSelection();
index 1a789675b720cfc173c3779c727b844c379bfa5c..c13cd76290a028ddaa8a064dadfccecb14c5f1ab 100644 (file)
@@ -6,9 +6,11 @@ import { RouterModule } from '@angular/router';
 import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
 import { PopoverModule } from 'ngx-bootstrap/popover';
 import { TabsModule } from 'ngx-bootstrap/tabs';
+import { TooltipModule } from 'ngx-bootstrap/tooltip';
 
 import { ServicesModule } from '../../shared/services/services.module';
 import { SharedModule } from '../../shared/shared.module';
+import { BlockModule } from '../block/block.module';
 import { CephSharedModule } from '../shared/ceph-shared.module';
 import { ErasureCodeProfileFormComponent } from './erasure-code-profile-form/erasure-code-profile-form.component';
 import { PoolDetailsComponent } from './pool-details/pool-details.component';
@@ -25,7 +27,9 @@ import { PoolListComponent } from './pool-list/pool-list.component';
     RouterModule,
     ReactiveFormsModule,
     BsDropdownModule,
-    ServicesModule
+    ServicesModule,
+    TooltipModule.forRoot(),
+    BlockModule
   ],
   exports: [PoolListComponent, PoolFormComponent],
   declarations: [
index 3ff9d5e9a163878adbfc9c4a8628019e84b67589..17be0f1d8e1a8d1299c799468326aed95de7de8a 100644 (file)
@@ -67,6 +67,7 @@ export class Pool {
     wr?: PoolStat;
   };
   cdIsBinary?: boolean;
+  configuration: { source: number; name: string; value: string }[];
 
   constructor(name) {
     this.pool_name = name;
index a2acdc687ceef83325375152d7722c15b64fa7e6..20ff612d65d39826c6498dd8df8d3a0778f8b0e0 100644 (file)
@@ -1,7 +1,9 @@
 import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
 import { fakeAsync, TestBed, tick } from '@angular/core/testing';
 
-import { configureTestBed } from '../../../testing/unit-test-helper';
+import { configureTestBed, i18nProviders } from '../../../testing/unit-test-helper';
+import { RbdConfigurationSourceField } from '../models/configuration';
+import { RbdConfigurationService } from '../services/rbd-configuration.service';
 import { PoolService } from './pool.service';
 
 describe('PoolService', () => {
@@ -10,7 +12,7 @@ describe('PoolService', () => {
   const apiPath = 'api/pool';
 
   configureTestBed({
-    providers: [PoolService],
+    providers: [PoolService, RbdConfigurationService, i18nProviders],
     imports: [HttpClientTestingModule]
   });
 
@@ -79,4 +81,43 @@ describe('PoolService', () => {
     tick();
     expect(result).toEqual(['foo', 'bar']);
   }));
+
+  it('should test injection of data from getConfiguration()', fakeAsync(() => {
+    const pool = 'foo';
+    let value;
+    service.getConfiguration(pool).subscribe((next) => (value = next));
+    const req = httpTesting.expectOne(`${apiPath}/${pool}/configuration`);
+    expect(req.request.method).toBe('GET');
+    req.flush([
+      {
+        name: 'rbd_qos_bps_limit',
+        value: '60',
+        source: RbdConfigurationSourceField.global
+      },
+      {
+        name: 'rbd_qos_iops_limit',
+        value: '0',
+        source: RbdConfigurationSourceField.global
+      }
+    ]);
+    tick();
+    expect(value).toEqual([
+      {
+        description: 'The desired limit of IO bytes per second.',
+        displayName: 'BPS Limit',
+        name: 'rbd_qos_bps_limit',
+        source: RbdConfigurationSourceField.global,
+        type: 0,
+        value: '60'
+      },
+      {
+        description: 'The desired limit of IO operations per second.',
+        displayName: 'IOPS Limit',
+        name: 'rbd_qos_iops_limit',
+        source: RbdConfigurationSourceField.global,
+        type: 1,
+        value: '0'
+      }
+    ]);
+  }));
 });
index 12aa43c9fab846de5aef94d3a7f92efdab37ec4e..0b70dbda73a997bf99eb665186803de9bbc3aed5 100644 (file)
@@ -2,9 +2,11 @@ import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
 import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
 
 import { cdEncode } from '../decorators/cd-encode';
-import { PoolFormInfo } from '../models/pool-form-info';
+import { RbdConfigurationEntry } from '../models/configuration';
+import { RbdConfigurationService } from '../services/rbd-configuration.service';
 import { ApiModule } from './api.module';
 
 @cdEncode
@@ -14,7 +16,7 @@ import { ApiModule } from './api.module';
 export class PoolService {
   apiPath = 'api/pool';
 
-  constructor(private http: HttpClient) {}
+  constructor(private http: HttpClient, private rbdConfigurationService: RbdConfigurationService) {}
 
   create(pool) {
     return this.http.post(this.apiPath, pool, { observe: 'response' });
@@ -44,8 +46,19 @@ export class PoolService {
     return this.http.get(`${this.apiPath}?stats=true`);
   }
 
-  getInfo(): Observable<PoolFormInfo> {
-    return this.http.get<PoolFormInfo>(`${this.apiPath}/_info`);
+  getConfiguration(poolName: string): Observable<RbdConfigurationEntry[]> {
+    return this.http.get<RbdConfigurationEntry[]>(`${this.apiPath}/${poolName}/configuration`).pipe(
+      // Add static data maintained in RbdConfigurationService
+      map((values) =>
+        values.map((entry) =>
+          Object.assign(entry, this.rbdConfigurationService.getOptionByName(entry.name))
+        )
+      )
+    );
+  }
+
+  getInfo(pool_name?: string) {
+    return this.http.get(`${this.apiPath}/_info` + (pool_name ? `?pool_name=${pool_name}` : ''));
   }
 
   list(attrs = []) {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts
new file mode 100644 (file)
index 0000000..af646d8
--- /dev/null
@@ -0,0 +1,29 @@
+import { RbdConfigurationEntry } from '../models/configuration';
+
+export interface RbdPool {
+  pool_name: string;
+  status: number;
+  value: RbdImage[];
+}
+
+export interface RbdImage {
+  disk_usage: number;
+  stripe_unit: number;
+  name: string;
+  parent: any;
+  pool_name: string;
+  num_objs: number;
+  block_name_prefix: string;
+  snapshots: any[];
+  obj_size: number;
+  data_pool: string;
+  total_disk_usage: number;
+  features: number;
+  configuration: RbdConfigurationEntry[];
+  timestamp: string;
+  id: string;
+  features_name: string[];
+  stripe_count: number;
+  order: number;
+  size: number;
+}
index 1fba5e58d25fbbf0c2896b4a8ca81a1f4d212776..b09fec663a166b9d0d9a50e75155f49a01f86829 100644 (file)
@@ -1,7 +1,8 @@
 import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
 
-import { configureTestBed } from '../../../testing/unit-test-helper';
+import { configureTestBed, i18nProviders } from '../../../testing/unit-test-helper';
+import { RbdConfigurationService } from '../services/rbd-configuration.service';
 import { RbdService } from './rbd.service';
 
 describe('RbdService', () => {
@@ -9,7 +10,7 @@ describe('RbdService', () => {
   let httpTesting: HttpTestingController;
 
   configureTestBed({
-    providers: [RbdService],
+    providers: [RbdService, RbdConfigurationService, i18nProviders],
     imports: [HttpClientTestingModule]
   });
 
index 02e1bcdc1522eeffb396a81b211c9034cd67b193..3ccf6100254ece192b67da4d725b1cc1710ae072 100644 (file)
@@ -1,15 +1,19 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
+import { map } from 'rxjs/operators';
+
 import { cdEncode, cdEncodeNot } from '../decorators/cd-encode';
+import { RbdConfigurationService } from '../services/rbd-configuration.service';
 import { ApiModule } from './api.module';
+import { RbdPool } from './rbd.model';
 
 @cdEncode
 @Injectable({
   providedIn: ApiModule
 })
 export class RbdService {
-  constructor(private http: HttpClient) {}
+  constructor(private http: HttpClient, private rbdConfigurationService: RbdConfigurationService) {}
 
   create(rbd) {
     return this.http.post('api/block/image', rbd, { observe: 'response' });
@@ -28,7 +32,22 @@ export class RbdService {
   }
 
   list() {
-    return this.http.get('api/block/image');
+    return this.http.get<RbdPool[]>('api/block/image').pipe(
+      map((pools) =>
+        pools.map((pool) => {
+          pool.value.map((image) => {
+            if (!image.configuration) {
+              return image;
+            }
+            image.configuration.map((option) =>
+              Object.assign(option, this.rbdConfigurationService.getOptionByName(option.name))
+            );
+            return image;
+          });
+          return pool;
+        })
+      )
+    );
   }
 
   copy(poolName, rbdName, rbd) {
index b289882237e3f67245ca06d7a86ec0a661b8025d..906fb64d49ce1bb68cbe67741cf3ab3ec2faf2ba 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, ElementRef, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core';
 import { Input } from '@angular/core';
 
-import { ChartTooltip } from '../../../shared/models/chart-tooltip';
+import { ChartTooltip } from '../../models/chart-tooltip';
 import { DimlessBinaryPipe } from '../../pipes/dimless-binary.pipe';
 
 @Component({
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.spec.ts
new file mode 100644 (file)
index 0000000..858becc
--- /dev/null
@@ -0,0 +1,12 @@
+import { DimlessBinaryPerSecondDirective } from './dimless-binary-per-second.directive';
+
+export class MockElementRef {
+  nativeElement: {};
+}
+
+describe('DimlessBinaryPerSecondDirective', () => {
+  it('should create an instance', () => {
+    const directive = new DimlessBinaryPerSecondDirective(new MockElementRef(), null, null, null);
+    expect(directive).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.ts
new file mode 100644 (file)
index 0000000..6355fd7
--- /dev/null
@@ -0,0 +1,132 @@
+import {
+  Directive,
+  ElementRef,
+  EventEmitter,
+  HostListener,
+  Input,
+  OnInit,
+  Output
+} from '@angular/core';
+import { NgControl } from '@angular/forms';
+
+import * as _ from 'lodash';
+
+import { DimlessBinaryPerSecondPipe } from '../pipes/dimless-binary-per-second.pipe';
+import { FormatterService } from '../services/formatter.service';
+
+@Directive({
+  selector: '[cdDimlessBinaryPerSecond]'
+})
+export class DimlessBinaryPerSecondDirective implements OnInit {
+  @Output()
+  ngModelChange: EventEmitter<any> = new EventEmitter();
+
+  /**
+   * Event emitter for letting this directive know that the data has (asynchronously) been loaded
+   * and the value needs to be adapted by this directive.
+   */
+  @Input()
+  ngDataReady: EventEmitter<any>;
+
+  /**
+   * Minimum size in bytes.
+   * If user enter a value lower than <minBytes>,
+   * the model will automatically be update to <minBytes>.
+   *
+   * If <roundPower> is used, this value should be a power of <roundPower>.
+   *
+   * Example:
+   *   Given minBytes=4096 (4KiB), if user type 1KiB, then model will be updated to 4KiB
+   */
+  @Input()
+  minBytes: number;
+
+  /**
+   * Maximum size in bytes.
+   * If user enter a value greater than <maxBytes>,
+   * the model will automatically be update to <maxBytes>.
+   *
+   * If <roundPower> is used, this value should be a power of <roundPower>.
+   *
+   * Example:
+   *   Given maxBytes=3145728 (3MiB), if user type 4MiB, then model will be updated to 3MiB
+   */
+  @Input()
+  maxBytes: number;
+
+  /**
+   * Value will be rounded up the nearest power of <roundPower>
+   *
+   * Example:
+   *   Given roundPower=2, if user type 7KiB, then model will be updated to 8KiB
+   *   Given roundPower=2, if user type 5KiB, then model will be updated to 4KiB
+   */
+  @Input()
+  roundPower: number;
+
+  /**
+   * Default unit that should be used when user do not type a unit.
+   * By default, "MiB" will be used.
+   *
+   * Example:
+   *   Given defaultUnit=null, if user type 7, then model will be updated to 7MiB
+   *   Given defaultUnit=k, if user type 7, then model will be updated to 7KiB
+   */
+  @Input()
+  defaultUnit: string;
+
+  private el: HTMLInputElement;
+
+  constructor(
+    private elementRef: ElementRef,
+    private control: NgControl,
+    private dimlessBinaryPerSecondPipe: DimlessBinaryPerSecondPipe,
+    private formatter: FormatterService
+  ) {
+    this.el = this.elementRef.nativeElement;
+  }
+
+  ngOnInit() {
+    this.setValue(this.el.value);
+    if (this.ngDataReady) {
+      this.ngDataReady.subscribe(() => this.setValue(this.el.value));
+    }
+  }
+
+  setValue(value) {
+    if (/^[\d.]+$/.test(value)) {
+      value += this.defaultUnit || 'm';
+    }
+    const size = this.formatter.toBytes(value, 0);
+    const roundedSize = this.round(size);
+    this.el.value = this.dimlessBinaryPerSecondPipe.transform(roundedSize);
+    if (size !== null) {
+      this.ngModelChange.emit(this.el.value);
+      this.control.control.setValue(this.el.value);
+    } else {
+      this.ngModelChange.emit(null);
+      this.control.control.setValue(null);
+    }
+  }
+
+  round(size) {
+    if (size !== null && size !== 0) {
+      if (!_.isUndefined(this.minBytes) && size < this.minBytes) {
+        return this.minBytes;
+      }
+      if (!_.isUndefined(this.maxBytes) && size > this.maxBytes) {
+        return this.maxBytes;
+      }
+      if (!_.isUndefined(this.roundPower)) {
+        const power = Math.round(Math.log(size) / Math.log(this.roundPower));
+        return Math.pow(this.roundPower, power);
+      }
+    }
+    return size;
+  }
+
+  @HostListener('blur', ['$event.target.value'])
+  onBlur(value) {
+    this.setValue(value);
+  }
+}
index e2d9f7d90d0dbafc8bba08ecb72973f0622591b2..a521a6c0c058d4b715e82e745eea48316590d965 100644 (file)
@@ -2,7 +2,10 @@ import { NgModule } from '@angular/core';
 
 import { AutofocusDirective } from './autofocus.directive';
 import { Copy2ClipboardButtonDirective } from './copy2clipboard-button.directive';
+import { DimlessBinaryPerSecondDirective } from './dimless-binary-per-second.directive';
 import { DimlessBinaryDirective } from './dimless-binary.directive';
+import { IopsDirective } from './iops.directive';
+import { MillisecondsDirective } from './milliseconds.directive';
 import { PasswordButtonDirective } from './password-button.directive';
 
 @NgModule({
@@ -11,13 +14,19 @@ import { PasswordButtonDirective } from './password-button.directive';
     AutofocusDirective,
     Copy2ClipboardButtonDirective,
     DimlessBinaryDirective,
-    PasswordButtonDirective
+    DimlessBinaryPerSecondDirective,
+    PasswordButtonDirective,
+    MillisecondsDirective,
+    IopsDirective
   ],
   exports: [
     AutofocusDirective,
     Copy2ClipboardButtonDirective,
     DimlessBinaryDirective,
-    PasswordButtonDirective
+    DimlessBinaryPerSecondDirective,
+    PasswordButtonDirective,
+    MillisecondsDirective,
+    IopsDirective
   ],
   providers: []
 })
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.spec.ts
new file mode 100644 (file)
index 0000000..9c1641d
--- /dev/null
@@ -0,0 +1,8 @@
+import { IopsDirective } from './iops.directive';
+
+describe('IopsDirective', () => {
+  it('should create an instance', () => {
+    const directive = new IopsDirective(null, null);
+    expect(directive).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.ts
new file mode 100644 (file)
index 0000000..5d5d22d
--- /dev/null
@@ -0,0 +1,31 @@
+import { Directive, EventEmitter, HostListener, Input, OnInit } from '@angular/core';
+import { NgControl } from '@angular/forms';
+
+import { FormatterService } from '../services/formatter.service';
+
+@Directive({
+  selector: '[cdIops]'
+})
+export class IopsDirective implements OnInit {
+  @Input()
+  ngDataReady: EventEmitter<any>;
+
+  constructor(private formatter: FormatterService, private ngControl: NgControl) {}
+
+  setValue(value: string): void {
+    const iops = this.formatter.toIops(value);
+    this.ngControl.control.setValue(`${iops} IOPS`);
+  }
+
+  ngOnInit(): void {
+    this.setValue(this.ngControl.value);
+    if (this.ngDataReady) {
+      this.ngDataReady.subscribe(() => this.setValue(this.ngControl.value));
+    }
+  }
+
+  @HostListener('blur', ['$event.target.value'])
+  onUpdate(value) {
+    this.setValue(value);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.spec.ts
new file mode 100644 (file)
index 0000000..5038020
--- /dev/null
@@ -0,0 +1,8 @@
+import { MillisecondsDirective } from './milliseconds.directive';
+
+describe('MillisecondsDirective', () => {
+  it('should create an instance', () => {
+    const directive = new MillisecondsDirective(null, null);
+    expect(directive).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.ts
new file mode 100644 (file)
index 0000000..4d84499
--- /dev/null
@@ -0,0 +1,31 @@
+import { Directive, EventEmitter, HostListener, Input, OnInit } from '@angular/core';
+import { NgControl } from '@angular/forms';
+
+import { FormatterService } from '../services/formatter.service';
+
+@Directive({
+  selector: '[cdMilliseconds]'
+})
+export class MillisecondsDirective implements OnInit {
+  @Input()
+  ngDataReady: EventEmitter<any>;
+
+  constructor(private control: NgControl, private formatter: FormatterService) {}
+
+  setValue(value: string): void {
+    const ms = this.formatter.toMilliseconds(value);
+    this.control.control.setValue(`${ms} ms`);
+  }
+
+  ngOnInit(): void {
+    this.setValue(this.control.value);
+    if (this.ngDataReady) {
+      this.ngDataReady.subscribe(() => this.setValue(this.control.value));
+    }
+  }
+
+  @HostListener('blur', ['$event.target.value'])
+  onUpdate(value) {
+    this.setValue(value);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/configuration.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/configuration.ts
new file mode 100644 (file)
index 0000000..0a8e403
--- /dev/null
@@ -0,0 +1,43 @@
+export enum RbdConfigurationSourceField {
+  global = 0,
+  pool = 1,
+  image = 2
+}
+
+export enum RbdConfigurationType {
+  bps,
+  iops,
+  milliseconds
+}
+
+/**
+ * This configuration can also be set on a pool level.
+ */
+export interface RbdConfigurationEntry {
+  name: string;
+  source: RbdConfigurationSourceField;
+  value: any;
+  type?: RbdConfigurationType; // Non-external field.
+  description?: string; // Non-external field.
+  displayName?: string; // Non-external field. Nice name for the UI which is added in the UI.
+}
+
+/**
+ * This object contains additional information injected into the elements retrieved by the service.
+ */
+export interface RbdConfigurationExtraField {
+  name: string;
+  displayName: string;
+  description: string;
+  type: RbdConfigurationType;
+  readOnly?: boolean;
+}
+
+/**
+ * Represents a set of data to be used for editing or creating configuration options
+ */
+export interface RbdConfigurationSection {
+  heading: string;
+  class: string;
+  options: RbdConfigurationExtraField[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-second.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-second.pipe.ts
new file mode 100644 (file)
index 0000000..bcd8f66
--- /dev/null
@@ -0,0 +1,23 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { FormatterService } from '../services/formatter.service';
+
+@Pipe({
+  name: 'dimlessBinaryPerSecond'
+})
+export class DimlessBinaryPerSecondPipe implements PipeTransform {
+  constructor(private formatter: FormatterService) {}
+
+  transform(value: any, args?: any): any {
+    return this.formatter.format_number(value, 1024, [
+      'B/s',
+      'kB/s',
+      'MB/s',
+      'GB/s',
+      'TB/s',
+      'PB/s',
+      'EB/s',
+      'ZB/s',
+      'YB/s'
+    ]);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.spec.ts
new file mode 100644 (file)
index 0000000..dac353d
--- /dev/null
@@ -0,0 +1,8 @@
+import { IopsPipe } from './iops.pipe';
+
+describe('IopsPipe', () => {
+  it('create an instance', () => {
+    const pipe = new IopsPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.ts
new file mode 100644 (file)
index 0000000..921e773
--- /dev/null
@@ -0,0 +1,10 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'iops'
+})
+export class IopsPipe implements PipeTransform {
+  transform(value: any, args?: any): any {
+    return `${value} IOPS`;
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.spec.ts
new file mode 100644 (file)
index 0000000..cea4bb1
--- /dev/null
@@ -0,0 +1,8 @@
+import { MillisecondsPipe } from './milliseconds.pipe';
+
+describe('MillisecondsPipe', () => {
+  it('create an instance', () => {
+    const pipe = new MillisecondsPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.ts
new file mode 100644 (file)
index 0000000..c2e45ca
--- /dev/null
@@ -0,0 +1,10 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'milliseconds'
+})
+export class MillisecondsPipe implements PipeTransform {
+  transform(value: any, args?: any): any {
+    return `${value} ms`;
+  }
+}
index c7870011592cdb716188204d0ef888f3baacf705..ac89776424eff8be3b9e9075a7da74600255ef7c 100644 (file)
@@ -5,13 +5,16 @@ import { EmptyPipe } from '../empty.pipe';
 import { CdDatePipe } from './cd-date.pipe';
 import { CephReleaseNamePipe } from './ceph-release-name.pipe';
 import { CephShortVersionPipe } from './ceph-short-version.pipe';
+import { DimlessBinaryPerSecondPipe } from './dimless-binary-per-second.pipe';
 import { DimlessBinaryPipe } from './dimless-binary.pipe';
 import { DimlessPipe } from './dimless.pipe';
 import { EncodeUriPipe } from './encode-uri.pipe';
 import { FilterPipe } from './filter.pipe';
 import { HealthColorPipe } from './health-color.pipe';
+import { IopsPipe } from './iops.pipe';
 import { ListPipe } from './list.pipe';
 import { LogPriorityPipe } from './log-priority.pipe';
+import { MillisecondsPipe } from './milliseconds.pipe';
 import { OrdinalPipe } from './ordinal.pipe';
 import { RelativeDatePipe } from './relative-date.pipe';
 import { RoundPipe } from './round.pipe';
@@ -20,6 +23,7 @@ import { RoundPipe } from './round.pipe';
   imports: [CommonModule],
   declarations: [
     DimlessBinaryPipe,
+    DimlessBinaryPerSecondPipe,
     HealthColorPipe,
     DimlessPipe,
     CephShortVersionPipe,
@@ -32,10 +36,13 @@ import { RoundPipe } from './round.pipe';
     EmptyPipe,
     EncodeUriPipe,
     RoundPipe,
-    OrdinalPipe
+    OrdinalPipe,
+    MillisecondsPipe,
+    IopsPipe
   ],
   exports: [
     DimlessBinaryPipe,
+    DimlessBinaryPerSecondPipe,
     HealthColorPipe,
     DimlessPipe,
     CephShortVersionPipe,
@@ -48,13 +55,16 @@ import { RoundPipe } from './round.pipe';
     EmptyPipe,
     EncodeUriPipe,
     RoundPipe,
-    OrdinalPipe
+    OrdinalPipe,
+    MillisecondsPipe,
+    IopsPipe
   ],
   providers: [
     DatePipe,
     CephShortVersionPipe,
     CephReleaseNamePipe,
     DimlessBinaryPipe,
+    DimlessBinaryPerSecondPipe,
     DimlessPipe,
     RelativeDatePipe,
     ListPipe,
@@ -62,7 +72,9 @@ import { RoundPipe } from './round.pipe';
     CdDatePipe,
     EmptyPipe,
     EncodeUriPipe,
-    OrdinalPipe
+    OrdinalPipe,
+    IopsPipe,
+    MillisecondsPipe
   ]
 })
 export class PipesModule {}
index bb80765d5fd40daaa46315f2394c8a0ee193997c..51ac884150d04cd0850b77ff6d46784037e7a0f2 100644 (file)
@@ -32,15 +32,17 @@ export class FormatterService {
   /**
    * Convert the given value into bytes.
    * @param {string} value The value to be converted, e.g. 1024B, 10M, 300KiB or 1ZB.
-   * @returns Returns the given value in bytes without any appended unit or null in case
-   *   of an error.
+   * @param error_value The value returned in case the regular expression did not match. Defaults to
+   *                    null.
+   * @returns Returns the given value in bytes without any unit appended or the defined error value
+   *          in case xof an error.
    */
-  toBytes(value: string): number | null {
+  toBytes(value: string, error_value = null): number | null {
     const base = 1024;
     const units = ['b', 'k', 'm', 'g', 't', 'p', 'e', 'z', 'y'];
-    const m = RegExp('^(\\d+(.\\d+)?) ?([' + units.join('') + '](b|ib)?)?$', 'i').exec(value);
+    const m = RegExp('^(\\d+(.\\d+)?) ?([' + units.join('') + ']?(b|ib|B/s)?)?$', 'i').exec(value);
     if (m === null) {
-      return null;
+      return error_value;
     }
     let bytes = parseFloat(m[1]);
     if (_.isString(m[3])) {
@@ -48,4 +50,32 @@ export class FormatterService {
     }
     return Math.round(bytes);
   }
+
+  /**
+   * Converts `x ms` to `x` (currently) or `0` if the conversion fails
+   */
+  toMilliseconds(value: string): number {
+    const pattern = /^\s*(\d+)\s*(ms)?\s*$/i;
+    const testResult = pattern.exec(value);
+
+    if (testResult !== null) {
+      return +testResult[1];
+    }
+
+    return 0;
+  }
+
+  /**
+   * Converts `x IOPS` to `x` (currently) or `0` if the conversion fails
+   */
+  toIops(value: string): number {
+    const pattern = /^\s*(\d+)\s*(IOPS)?\s*$/i;
+    const testResult = pattern.exec(value);
+
+    if (testResult !== null) {
+      return +testResult[1];
+    }
+
+    return 0;
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.spec.ts
new file mode 100644 (file)
index 0000000..3b9c043
--- /dev/null
@@ -0,0 +1,45 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed, i18nProviders } from '../../../testing/unit-test-helper';
+import { RbdConfigurationType } from '../models/configuration';
+import { RbdConfigurationService } from './rbd-configuration.service';
+
+describe('RbdConfigurationService', () => {
+  let service: RbdConfigurationService;
+
+  configureTestBed({
+    providers: [RbdConfigurationService, i18nProviders]
+  });
+
+  beforeEach(() => {
+    service = TestBed.get(RbdConfigurationService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('should filter config options', () => {
+    const result = service.getOptionByName('rbd_qos_write_iops_burst');
+    expect(result).toEqual({
+      name: 'rbd_qos_write_iops_burst',
+      displayName: 'Write IOPS Burst',
+      description: 'The desired burst limit of write operations.',
+      type: RbdConfigurationType.iops
+    });
+  });
+
+  it('should return the display name', () => {
+    const displayName = service.getDisplayName('rbd_qos_write_iops_burst');
+    expect(displayName).toBe('Write IOPS Burst');
+  });
+
+  it('should return the description', () => {
+    const description = service.getDescription('rbd_qos_write_iops_burst');
+    expect(description).toBe('The desired burst limit of write operations.');
+  });
+
+  it('should have a class for each section', () => {
+    service.sections.forEach((section) => expect(section.class).toBeTruthy());
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.ts
new file mode 100644 (file)
index 0000000..cf56d64
--- /dev/null
@@ -0,0 +1,147 @@
+import { Injectable } from '@angular/core';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+
+import {
+  RbdConfigurationExtraField,
+  RbdConfigurationSection,
+  RbdConfigurationType
+} from '../models/configuration';
+import { ServicesModule } from './services.module';
+
+/**
+ * Define here which options should be made available under which section heading.
+ * The display name and description needs to be added manually as long as Ceph does not provide
+ * this information.
+ */
+@Injectable({
+  providedIn: ServicesModule
+})
+export class RbdConfigurationService {
+  readonly sections: RbdConfigurationSection[];
+
+  constructor(private i18n: I18n) {
+    this.sections = [
+      {
+        heading: this.i18n('Quality of Service'),
+        class: 'quality-of-service',
+        options: [
+          {
+            name: 'rbd_qos_bps_limit',
+            displayName: this.i18n('BPS Limit'),
+            description: this.i18n('The desired limit of IO bytes per second.'),
+            type: RbdConfigurationType.bps
+          },
+          {
+            name: 'rbd_qos_iops_limit',
+            displayName: this.i18n('IOPS Limit'),
+            description: this.i18n('The desired limit of IO operations per second.'),
+            type: RbdConfigurationType.iops
+          },
+          {
+            name: 'rbd_qos_read_bps_limit',
+            displayName: this.i18n('Read BPS Limit'),
+            description: this.i18n('The desired limit of read bytes per second.'),
+            type: RbdConfigurationType.bps
+          },
+          {
+            name: 'rbd_qos_read_iops_limit',
+            displayName: this.i18n('Read IOPS Limit'),
+            description: this.i18n('The desired limit of read operations per second.'),
+            type: RbdConfigurationType.iops
+          },
+          {
+            name: 'rbd_qos_write_bps_limit',
+            displayName: this.i18n('Write BPS Limit'),
+            description: this.i18n('The desired limit of write bytes per second.'),
+            type: RbdConfigurationType.bps
+          },
+          {
+            name: 'rbd_qos_write_iops_limit',
+            displayName: this.i18n('Write IOPS Limit'),
+            description: this.i18n('The desired limit of write operations per second.'),
+            type: RbdConfigurationType.iops
+          },
+          {
+            name: 'rbd_qos_bps_burst',
+            displayName: this.i18n('BPS Burst'),
+            description: this.i18n('The desired burst limit of IO bytes.'),
+            type: RbdConfigurationType.bps
+          },
+          {
+            name: 'rbd_qos_iops_burst',
+            displayName: this.i18n('IOPS Burst'),
+            description: this.i18n('The desired burst limit of IO operations.'),
+            type: RbdConfigurationType.iops
+          },
+          {
+            name: 'rbd_qos_read_bps_burst',
+            displayName: this.i18n('Read BPS Burst'),
+            description: this.i18n('The desired burst limit of read bytes.'),
+            type: RbdConfigurationType.bps
+          },
+          {
+            name: 'rbd_qos_read_iops_burst',
+            displayName: this.i18n('Read IOPS Burst'),
+            description: this.i18n('The desired burst limit of read operations.'),
+            type: RbdConfigurationType.iops
+          },
+          {
+            name: 'rbd_qos_write_bps_burst',
+            displayName: this.i18n('Write BPS Burst'),
+            description: this.i18n('The desired burst limit of write bytes.'),
+            type: RbdConfigurationType.bps
+          },
+          {
+            name: 'rbd_qos_write_iops_burst',
+            displayName: this.i18n('Write IOPS Burst'),
+            description: this.i18n('The desired burst limit of write operations.'),
+            type: RbdConfigurationType.iops
+          }
+        ] as RbdConfigurationExtraField[]
+      }
+    ];
+  }
+
+  private static getOptionsFromSections(sections: RbdConfigurationSection[]) {
+    return sections.map((section) => section.options).reduce((a, b) => a.concat(b));
+  }
+
+  private filterConfigOptionsByName(configName: string) {
+    return RbdConfigurationService.getOptionsFromSections(this.sections).filter(
+      (option) => option.name === configName
+    );
+  }
+
+  private getOptionValueByName(configName: string, fieldName: string, defaultValue = '') {
+    const configOptions = this.filterConfigOptionsByName(configName);
+    return configOptions.length === 1 ? configOptions.pop()[fieldName] : defaultValue;
+  }
+
+  getWritableSections() {
+    return this.sections.map((section) => {
+      section.options = section.options.filter((o) => !o.readOnly);
+      return section;
+    });
+  }
+
+  getOptionFields() {
+    return RbdConfigurationService.getOptionsFromSections(this.sections);
+  }
+
+  getWritableOptionFields() {
+    return RbdConfigurationService.getOptionsFromSections(this.getWritableSections());
+  }
+
+  getOptionByName(optionName: string): RbdConfigurationExtraField {
+    return this.filterConfigOptionsByName(optionName).pop();
+  }
+
+  getDisplayName(configName: string): string {
+    return this.getOptionValueByName(configName, 'displayName');
+  }
+
+  getDescription(configName: string): string {
+    return this.getOptionValueByName(configName, 'description');
+  }
+}
index a6b883cc654ee39ff5b619e6965ad61f0041d3bb..2464e1c35d9ce960ba7cb998b6b2f93151ab2e20 100644 (file)
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
           <context context-type="linenumber">78</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
+          <context context-type="linenumber">125</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/pool/pool-details/pool-details.component.html</context>
+          <context context-type="linenumber">22</context>
+        </context-group>
       </trans-unit><trans-unit id="8c95898abff46bfac3ed6eb2afef74597e60b15c" datatype="html">
         <source>CRUSH map</source>
         <context-group purpose="location">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">303</context>
+          <context context-type="linenumber">315</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/cluster/configuration/configuration-form/configuration-form.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">443</context>
+          <context context-type="linenumber">451</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">49</context>
+          <context context-type="linenumber">51</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">91</context>
+          <context context-type="linenumber">93</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">151</context>
+          <context context-type="linenumber">153</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">175</context>
+          <context context-type="linenumber">177</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">36</context>
+          <context context-type="linenumber">38</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/cluster/configuration/configuration-form/configuration-form.component.html</context>
         <source>{VAR_SELECT, select, editing {Edit} cloning {Clone} copying {Copy} other {Add} }</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">10</context>
+          <context context-type="linenumber">11</context>
         </context-group>
       </trans-unit><trans-unit id="52dd89f49fc440660cbbb3665b88d80f5baa7437" datatype="html">
         <source>{VAR_SELECT, select, cloning {Clone from} copying {Copy from} other {Parent} }</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">20</context>
+          <context context-type="linenumber">22</context>
         </context-group>
       </trans-unit><trans-unit id="94516fa213706c67ce5a5b5765681d7fb032033a" datatype="html">
         <source>Loading...</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">79</context>
+          <context context-type="linenumber">81</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">139</context>
+          <context context-type="linenumber">141</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/nfs/nfs-form/nfs-form.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">341</context>
+          <context context-type="linenumber">342</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html</context>
         <source>-- No rbd pools available --</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">82</context>
+          <context context-type="linenumber">84</context>
         </context-group>
       </trans-unit><trans-unit id="ef83ec9c304a89d45650e580dcdc2978c37b3a83" datatype="html">
         <source>-- Select a pool --</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">85</context>
+          <context context-type="linenumber">87</context>
         </context-group>
       </trans-unit><trans-unit id="49449943d8cbf59d8c401c8bd2e76f92e207cc5f" datatype="html">
         <source>Use a dedicated data pool</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">105</context>
+          <context context-type="linenumber">107</context>
         </context-group>
       </trans-unit><trans-unit id="870aee0dd31a9643bf62007beb8f1ae1deb34d42" datatype="html">
         <source>Data pool</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">116</context>
+          <context context-type="linenumber">118</context>
         </context-group>
       </trans-unit><trans-unit id="3792ca829d9b9f687e1f5d7733d30e9bb0bfec47" datatype="html">
         <source>Dedicated pool that stores the object-data of the RBD.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">120</context>
+          <context context-type="linenumber">122</context>
         </context-group>
       </trans-unit><trans-unit id="151c80ea931037cd92e854442927f8a0f6ae7795" datatype="html">
         <source>-- No data pools available --</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">142</context>
+          <context context-type="linenumber">144</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/nfs/nfs-form/nfs-form.component.html</context>
         <source>Size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">160</context>
+          <context context-type="linenumber">162</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
         <source>e.g., 10GiB</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">170</context>
+          <context context-type="linenumber">172</context>
         </context-group>
       </trans-unit><trans-unit id="0a88bbee20570aaf9615332fb27020627044874d" datatype="html">
         <source>You have to increase the size.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">178</context>
+          <context context-type="linenumber">180</context>
         </context-group>
       </trans-unit><trans-unit id="6cdb1fea93d77c07950c0c76c6e0ad79ebbef084" datatype="html">
         <source>Features</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">188</context>
+          <context context-type="linenumber">190</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
         <source>Advanced...</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">211</context>
+          <context context-type="linenumber">213</context>
         </context-group>
       </trans-unit><trans-unit id="bc2e854e111ecf2bd7db170da5e3c2ed08181d88" datatype="html">
         <source>Advanced</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">217</context>
+          <context context-type="linenumber">219</context>
+        </context-group>
+      </trans-unit><trans-unit id="3562a3778695a5f9c0445660e35301f0a39aaf73" datatype="html">
+        <source>Striping</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
+          <context context-type="linenumber">222</context>
         </context-group>
       </trans-unit><trans-unit id="ceac8e132384322ec778ba760875a6c6897d3e42" datatype="html">
         <source>Object size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">224</context>
+          <context context-type="linenumber">229</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
         <source>Stripe unit</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">241</context>
+          <context context-type="linenumber">246</context>
         </context-group>
       </trans-unit><trans-unit id="84471be1049006edecbcaef1a32ae0893c229c50" datatype="html">
         <source>-- Select stripe unit --</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">252</context>
+          <context context-type="linenumber">257</context>
         </context-group>
       </trans-unit><trans-unit id="8d32c5c54c8581c774a7f467fbd4e329b15a74fa" datatype="html">
         <source>This field is required because stripe count is defined!</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">258</context>
+          <context context-type="linenumber">263</context>
         </context-group>
       </trans-unit><trans-unit id="6bbf9040be7c5491d4a03f2185708f43a6582a3b" datatype="html">
         <source>Stripe unit is greater than object size.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">261</context>
+          <context context-type="linenumber">266</context>
         </context-group>
       </trans-unit><trans-unit id="a682f49f9b761591661276d7c6f550e641a130a4" datatype="html">
         <source>Stripe count</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">270</context>
+          <context context-type="linenumber">275</context>
         </context-group>
       </trans-unit><trans-unit id="baa74031990c5370008ba622d0a250f0929097f4" datatype="html">
         <source>This field is required because stripe unit is defined!</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">283</context>
+          <context context-type="linenumber">288</context>
         </context-group>
       </trans-unit><trans-unit id="cd2ada6d5ecbd5cbf89eae0a1f5326efedac0dbc" datatype="html">
         <source>Stripe count must be greater than 0.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">286</context>
+          <context context-type="linenumber">291</context>
+        </context-group>
+      </trans-unit><trans-unit id="21afa21d069c7fdfc742e6fbbe7e6c0f6ff4e580" datatype="html">
+        <source><x id="ICU" equiv-text="{mode, select, editing {...} cloning {...} copying {...} other {...}}"/> RBD</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
+          <context context-type="linenumber">310</context>
         </context-group>
       </trans-unit><trans-unit id="96d97d99d8c30942ace7d29cad9dcfb5d32315a1" datatype="html">
         <source>{VAR_SELECT, select, editing {Update} cloning {Clone} copying {Copy} other {Create} }</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
-          <context context-type="linenumber">298</context>
+          <context context-type="linenumber">310</context>
         </context-group>
       </trans-unit><trans-unit id="aba82bfd8e177d35b76cad7cd43941f8e5e5acac" datatype="html">
         <source>Trash</source>
         <source>Applications</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">290</context>
+          <context context-type="linenumber">291</context>
         </context-group>
       </trans-unit><trans-unit id="2208d63d5940ce656006a220102b1eb2b5e553da" datatype="html">
         <source>Compression</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">308</context>
+          <context context-type="linenumber">309</context>
         </context-group>
       </trans-unit><trans-unit id="37e10df2d9c0c25ef04ac112c9c9a7723e8efae0" datatype="html">
         <source>Mode</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">314</context>
+          <context context-type="linenumber">315</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html</context>
         <source>Algorithm</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">333</context>
+          <context context-type="linenumber">334</context>
         </context-group>
       </trans-unit><trans-unit id="4903231d42089325a28892c0fde1aed46b733ae6" datatype="html">
         <source>-- No erasure compression algorithm available --</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">344</context>
+          <context context-type="linenumber">345</context>
         </context-group>
       </trans-unit><trans-unit id="5d68ddb254275f8f44221e9ad6d8ceeb59ca46a6" datatype="html">
         <source>Minimum blob size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">358</context>
+          <context context-type="linenumber">359</context>
         </context-group>
       </trans-unit><trans-unit id="fb2f176df80647137cbb02bbeb29e5dec707a400" datatype="html">
         <source>e.g., 128KiB</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">367</context>
+          <context context-type="linenumber">368</context>
         </context-group>
       </trans-unit><trans-unit id="1b7f6e53a4521c6eb3ced4c007fdd4cf80bb7707" datatype="html">
         <source>Value should be greater than 0</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">372</context>
+          <context context-type="linenumber">373</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">397</context>
+          <context context-type="linenumber">398</context>
         </context-group>
       </trans-unit><trans-unit id="54be22af9625a595ee5d70917ee1fb116be7c4be" datatype="html">
         <source>Value should be greater than the maximum blob size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">375</context>
+          <context context-type="linenumber">376</context>
         </context-group>
       </trans-unit><trans-unit id="151efb127a9a4dd25259a0b2055442318a141f5b" datatype="html">
         <source>Maximum blob size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">384</context>
+          <context context-type="linenumber">385</context>
         </context-group>
       </trans-unit><trans-unit id="0c656f0e346bbadf46eb1a5d20d0307a3bd20ba8" datatype="html">
         <source>e.g., 512KiB</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">392</context>
+          <context context-type="linenumber">393</context>
         </context-group>
       </trans-unit><trans-unit id="0a65a24eee8a026f3b1113fe9e157e9a0dd69486" datatype="html">
         <source>Value should be greater than the minimum blob size</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">400</context>
+          <context context-type="linenumber">401</context>
         </context-group>
       </trans-unit><trans-unit id="261ba09c4a59de83f48f52a23fd328da37e61ff4" datatype="html">
         <source>Ratio</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">409</context>
+          <context context-type="linenumber">410</context>
         </context-group>
       </trans-unit><trans-unit id="c1430457a9c3c66366e51d76bf10396014c576be" datatype="html">
         <source>Compression ratio</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">420</context>
+          <context context-type="linenumber">421</context>
         </context-group>
       </trans-unit><trans-unit id="ae5ce6de352cde949998fb10754459c3a4eb183b" datatype="html">
         <source>Value should be between 0.0 and 1.0</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">423</context>
+          <context context-type="linenumber">424</context>
         </context-group>
       </trans-unit><trans-unit id="c6d3131ce5386154504dc91b5ad08855d85881d3" datatype="html">
         <source><x id="ICU" equiv-text="{editing, select, 1 {...} other {...}}"/> pool</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">438</context>
+          <context context-type="linenumber">446</context>
         </context-group>
       </trans-unit><trans-unit id="e2b15aacc2f17a9c3276895eeeaadd342ab26555" datatype="html">
         <source>{VAR_SELECT, select, 1 {Edit} other {Create} }</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-form/pool-form.component.html</context>
-          <context context-type="linenumber">438</context>
+          <context context-type="linenumber">446</context>
         </context-group>
       </trans-unit><trans-unit id="1d8a7c8aea58294a3c57c23af0468ddf0ba0c9c7" datatype="html">
         <source>Pools List</source>
           <context context-type="sourcefile">app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html</context>
           <context context-type="linenumber">12</context>
         </context-group>
+      </trans-unit><trans-unit id="5decb3917d46a9ac6e5813699801becb7c3c1455" datatype="html">
+        <source>Global</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html</context>
+          <context context-type="linenumber">10</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
+          <context context-type="linenumber">137</context>
+        </context-group>
+      </trans-unit><trans-unit id="a5f9ba9bb9faa8284bcadb1cdbc6aaf969e9c4bb" datatype="html">
+        <source>Image</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html</context>
+          <context context-type="linenumber">11</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
+          <context context-type="linenumber">135</context>
+        </context-group>
+      </trans-unit><trans-unit id="e70fcca5a99575cffef3ff8cbd5e69f06ffd0f1c" datatype="html">
+        <source>Pool</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html</context>
+          <context context-type="linenumber">12</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
+          <context context-type="linenumber">18</context>
+        </context-group>
+      </trans-unit><trans-unit id="ff92fbdec9fdd5054493eeda0d7ee8b450f83e72" datatype="html">
+        <source>RBD Configuration</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html</context>
+          <context context-type="linenumber">2</context>
+        </context-group>
+      </trans-unit><trans-unit id="b62d9efc8eb3b589904f6cb96a0406bbda55673a" datatype="html">
+        <source>Remove the local configuration value. The parent configuration value will be inherited and used instead.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html</context>
+          <context context-type="linenumber">60</context>
+        </context-group>
+      </trans-unit><trans-unit id="963488a1010d46067b238c4ae917fab3907c0a3a" datatype="html">
+        <source>The mininum value is 0</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html</context>
+          <context context-type="linenumber">73</context>
+        </context-group>
       </trans-unit><trans-unit id="91853167141c37b58868f3b0421383dd72fa8a01" datatype="html">
         <source>Attributes (OSD map)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
           <context context-type="linenumber">2</context>
         </context-group>
-      </trans-unit><trans-unit id="e70fcca5a99575cffef3ff8cbd5e69f06ffd0f1c" datatype="html">
-        <source>Pool</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
-          <context context-type="linenumber">18</context>
-        </context-group>
       </trans-unit><trans-unit id="03cc5b14b0a20d075e9009ff021f4f1660ba348a" datatype="html">
         <source>Data Pool</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
           <context context-type="linenumber">119</context>
         </context-group>
+      </trans-unit><trans-unit id="58e58f1a8786da9031a05e6770c5dafce82badf5" datatype="html">
+        <source>This setting overrides the global value</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
+          <context context-type="linenumber">135</context>
+        </context-group>
+      </trans-unit><trans-unit id="36b46714164964c6258b08ed0a25f57d8a950f92" datatype="html">
+        <source>This is the global value. No value for this option has been set for this image.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
+          <context context-type="linenumber">137</context>
+        </context-group>
       </trans-unit><trans-unit id="3f67f30568e9ae47507d46e28e1e82a7dca772e2" datatype="html">
         <source><x id="ICU" equiv-text="{ editing, select, true {...} other {...}}"/> RBD Snapshot</source>
         <context-group purpose="location">
         <source>Cache Tiers Details</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/pool/pool-details/pool-details.component.html</context>
-          <context context-type="linenumber">22</context>
+          <context context-type="linenumber">27</context>
         </context-group>
       </trans-unit><trans-unit id="0c1e17956453ad772dbe82d6946f62748c692f3e" datatype="html">
         <source>Ranks</source>
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="a5f9ba9bb9faa8284bcadb1cdbc6aaf969e9c4bb" datatype="html">
-        <source>Image</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/ceph/block/iscsi/iscsi.component.ts</context>
-          <context context-type="linenumber">1</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/ceph/block/mirroring/image-list/image-list.component.ts</context>
-          <context context-type="linenumber">1</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/ceph/block/mirroring/image-list/image-list.component.ts</context>
-          <context context-type="linenumber">1</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/ceph/block/mirroring/image-list/image-list.component.ts</context>
-          <context context-type="linenumber">1</context>
-        </context-group>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.ts</context>
-          <context context-type="linenumber">1</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="2cd86fc9c2dc1b4398514266a08e507140fe5ba8" datatype="html">
         <source>Active/Optimized</source>
         <context-group purpose="location">
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="a6493c6ca346cd052da40423eda9c132de2b2002" datatype="html">
+        <source>Key</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5049e204c14c648691ac775a64fb504467aeb549" datatype="html">
+        <source>Value</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="4130b1a836f2000cfe9a11fb62493dc42d444de7" datatype="html">
         <source>Deep flatten</source>
         <context-group purpose="location">
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="5049e204c14c648691ac775a64fb504467aeb549" datatype="html">
-        <source>Value</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.ts</context>
-          <context context-type="linenumber">1</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="60fb10c145f9c7ede2b9ddf9b2b0b0f6191d0ebd" datatype="html">
         <source>Cache Mode</source>
         <context-group purpose="location">
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="8fd98fb2a0f24ba72cde0787dd3f02cfc17de469" datatype="html">
+        <source>Quality of Service</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2c53f3ce2c6fff9025a7b41cc334521725da6e7b" datatype="html">
+        <source>BPS Limit</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="e897624a392419386b035bcd8529337e262fc41d" datatype="html">
+        <source>The desired limit of IO bytes per second.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="dc56f77e4507da7e86a3accd0a2a7099a838d539" datatype="html">
+        <source>IOPS Limit</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="141c3f6eadbb35b0e17b2f847e4cac23240a6ea9" datatype="html">
+        <source>The desired limit of IO operations per second.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3b3b101f16c38806e984b157e0cf22cd21451122" datatype="html">
+        <source>Read BPS Limit</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="87835fabb6a645873f3829a450ca1d58f97754f2" datatype="html">
+        <source>The desired limit of read bytes per second.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="28b485e700df46d70b96666099f714e3ae9e5c2e" datatype="html">
+        <source>Read IOPS Limit</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5907bd1a5cf3e20e740bca676cb1538372e1f9ff" datatype="html">
+        <source>The desired limit of read operations per second.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="07317121f0b194987d4dee8b322f521ac17bf124" datatype="html">
+        <source>Write BPS Limit</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4022c78bd9132ca2ec67927f8484c437d1d9b6fd" datatype="html">
+        <source>The desired limit of write bytes per second.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="52c22c19f98ff3a09bd811292954a889b93d28b7" datatype="html">
+        <source>Write IOPS Limit</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="0ed62e3fd8654b3191fc7fa959ad3f10f1ea773a" datatype="html">
+        <source>The desired limit of write operations per second.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="d90c99d77864f21f932ee1369019f400c4ad0e2d" datatype="html">
+        <source>BPS Burst</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="29c32192f4d185139aaaf6d3a766a3411dfeabbe" datatype="html">
+        <source>The desired burst limit of IO bytes.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="b988b90489e899941e196c3dd0ecc22e37540224" datatype="html">
+        <source>IOPS Burst</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="02c5de8ff11870f1b3896f2b45637ce0dba1ed4b" datatype="html">
+        <source>The desired burst limit of IO operations.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7a160538129b37cf595f1651f28b5fac62fbc0f8" datatype="html">
+        <source>Read BPS Burst</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="b573a5df16a53b9947288461bc24e6a0eb4341d6" datatype="html">
+        <source>The desired burst limit of read bytes.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1d5b8c2a0c653651cc4ca4b8fed9456f449220d1" datatype="html">
+        <source>Read IOPS Burst</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="20b199f9d4a8d08ffefec66040e27639ec221512" datatype="html">
+        <source>The desired burst limit of read operations.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="824525ae57992aee629ddffcd500a8b77087ddec" datatype="html">
+        <source>Write BPS Burst</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="a90aac4050a5d79765da5e1186909f6d4d7f981d" datatype="html">
+        <source>The desired burst limit of write bytes.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="35a8c6e6c249c7d16e26aa7c4ef97c06db405070" datatype="html">
+        <source>Write IOPS Burst</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="b356abd41d7e9eea4d20c03d2e6892b6d435ecbd" datatype="html">
+        <source>The desired burst limit of write operations.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/shared/services/rbd-configuration.service.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="24c4d50fe8567de381a20a1745f1b6d37eacaa90" datatype="html">
         <source>Failed to <x id="INTERPOLATION" equiv-text="{{failure}}"/> <x id="INTERPOLATION_1" equiv-text="{{metadata}}"/></source>
         <context-group purpose="location">
index 147374c26324975b016792ae13aa29207919d100..dfa92f0775b4ca28a2a67c0dd6fb719f08c07976 100644 (file)
@@ -358,3 +358,20 @@ uib-accordion .panel-title,
 .block-ui-wrapper {
   background: $color-transparent-black !important;
 }
+
+h3.page-header {
+  margin-left: 1em;
+  margin-top: 1em;
+  border-color: #f0f0f0;
+}
+
+.tooltip-wide .tooltip-inner {
+  width: 400px;
+}
+
+.tooltip-inner {
+  background-color: white;
+  border: 1px solid grey;
+  color: #333;
+  font-size: 1.1em;
+}
diff --git a/src/pybind/mgr/dashboard/services/rbd.py b/src/pybind/mgr/dashboard/services/rbd.py
new file mode 100644 (file)
index 0000000..bdc5362
--- /dev/null
@@ -0,0 +1,105 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import rbd
+from .. import mgr
+
+
+class RbdConfiguration(object):
+    _rbd = rbd.RBD()
+
+    def __init__(self, pool_name='', image_name='', pool_ioctx=None, image_ioctx=None):
+        # type: (str, str, object, object) -> None
+        assert bool(pool_name) != bool(pool_ioctx)  # xor
+        self._pool_name = pool_name
+        self._image_name = image_name
+        self._pool_ioctx = pool_ioctx
+        self._image_ioctx = image_ioctx
+
+    @staticmethod
+    def _ensure_prefix(option):
+        # type: (str) -> str
+        return option if option.startswith('conf_') else 'conf_' + option
+
+    def list(self):
+        # type: () -> [dict]
+        def _list(ioctx):
+            if self._image_name:  # image config
+                with rbd.Image(ioctx, self._image_name) as image:
+                    result = image.config_list()
+            else:  # pool config
+                result = self._rbd.config_list(ioctx)
+            return list(result)
+
+        if self._pool_name:
+            ioctx = mgr.rados.open_ioctx(self._pool_name)
+        else:
+            ioctx = self._pool_ioctx
+
+        return _list(ioctx)
+
+    def get(self, option_name):
+        # type: (str) -> str
+        option_name = self._ensure_prefix(option_name)
+        with mgr.rados.open_ioctx(self._pool_name) as pool_ioctx:
+            if self._image_name:
+                with rbd.Image(pool_ioctx, self._image_name) as image:
+                    return image.metadata_get(option_name)
+            return self._rbd.pool_metadata_get(pool_ioctx, option_name)
+
+    def set(self, option_name, option_value):
+        # type: (str, str) -> None
+
+        option_value = str(option_value)
+        option_name = self._ensure_prefix(option_name)
+
+        pool_ioctx = self._pool_ioctx
+        if self._pool_name:  # open ioctx
+            pool_ioctx = mgr.rados.open_ioctx(self._pool_name)
+            pool_ioctx.__enter__()
+
+        image_ioctx = self._image_ioctx
+        if self._image_name:
+            image_ioctx = rbd.Image(pool_ioctx, self._image_name)
+            image_ioctx.__enter__()
+
+        if image_ioctx:
+            image_ioctx.metadata_set(option_name, option_value)
+        else:
+            self._rbd.pool_metadata_set(pool_ioctx, option_name, option_value)
+
+        if self._image_name:  # Name provided, so we opened it and now have to close it
+            image_ioctx.__exit__(None, None, None)
+        if self._pool_name:
+            pool_ioctx.__exit__(None, None, None)
+
+    def remove(self, option_name):
+        """
+        Removes an option by name. Will not raise an error, if the option hasn't been found.
+        :type option_name str
+        """
+        def _remove(ioctx):
+            try:
+                if self._image_name:
+                    with rbd.Image(ioctx, self._image_name) as image:
+                        image.metadata_remove(option_name)
+                else:
+                    self._rbd.pool_metadata_remove(ioctx, option_name)
+            except KeyError:
+                pass
+
+        option_name = self._ensure_prefix(option_name)
+
+        if self._pool_name:
+            with mgr.rados.open_ioctx(self._pool_name) as pool_ioctx:
+                _remove(pool_ioctx)
+        else:
+            _remove(self._pool_ioctx)
+
+    def set_configuration(self, configuration):
+        if configuration:
+            for option_name, option_value in configuration.items():
+                if option_value is not None:
+                    self.set(option_name, option_value)
+                else:
+                    self.remove(option_name)
index 4b8f1bfa0c20c5fe0d00cc2c04bc10709f47325a..7eb23e535b70b4f42b38d51edfeadd2e906b048a 100644 (file)
@@ -876,7 +876,10 @@ def get_request_body_params(request):
     if content_type in ['application/json', 'text/javascript']:
         if not hasattr(request, 'json'):
             raise cherrypy.HTTPError(400, 'Expected JSON body')
-        params.update(request.json.items())
+        if isinstance(request.json, str):
+            params.update(json.loads(request.json))
+        else:
+            params.update(request.json)
 
     return params