]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add UI to configure the telemetry mgr plugin 25989/head
authorVolker Theile <vtheile@suse.com>
Thu, 7 Feb 2019 14:54:24 +0000 (15:54 +0100)
committerVolker Theile <vtheile@suse.com>
Tue, 12 Feb 2019 15:36:56 +0000 (16:36 +0100)
Fixes: tracker.ceph.com/issues/36488
Signed-off-by: Volker Theile <vtheile@suse.com>
28 files changed:
qa/suites/rados/mgr/tasks/dashboard.yaml
qa/tasks/ceph_test_case.py
qa/tasks/mgr/dashboard/helper.py
qa/tasks/mgr/dashboard/test_mgr_module.py [new file with mode: 0644]
qa/tasks/mgr/dashboard/test_rgw.py
src/pybind/mgr/dashboard/controllers/mgr_modules.py [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/package-lock.json
src/pybind/mgr/dashboard/frontend/package.json
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/app.component.html
src/pybind/mgr/dashboard/frontend/src/app/app.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules.module.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts
src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf
src/pybind/mgr/dashboard/frontend/src/styles.scss
src/pybind/mgr/dashboard/tools.py

index cda888a6aa8394dfbfcdb79aa131452b4c915ba5..0c2dfc880404ca80b610d4721781856106ed3407 100644 (file)
@@ -45,3 +45,4 @@ tasks:
         - tasks.mgr.dashboard.test_settings
         - tasks.mgr.dashboard.test_user
         - tasks.mgr.dashboard.test_erasure_code_profile
+        - tasks.mgr.dashboard.test_mgr_module
index adcf3fbecb33a104c7fbee89cdaee0c2d077489c..41a087abd8491a58da2fbee618a68a49f9686219 100644 (file)
@@ -153,8 +153,7 @@ class CephTestCase(unittest.TestCase):
         log.debug("wait_until_equal: success")
 
     @classmethod
-    def wait_until_true(cls, condition, timeout):
-        period = 5
+    def wait_until_true(cls, condition, timeout, period=5):
         elapsed = 0
         while True:
             if condition():
index 56fef68355020fe0f4e95a00207e5c13626835f0..a0beee32a403c8f25513ce9e46bff0fb7201187a 100644 (file)
@@ -402,6 +402,21 @@ class DashboardTestCase(MgrTestCase):
         j = json.loads(out)
         return [mon['name'] for mon in j['monmap']['mons']]
 
+    @classmethod
+    def find_object_in_list(cls, key, value, iterable):
+        """
+        Get the first occurrence of an object within a list with
+        the specified key/value.
+        :param key: The name of the key.
+        :param value: The value to search for.
+        :param iterable: The list to process.
+        :return: Returns the found object or None.
+        """
+        for obj in iterable:
+            if key in obj and obj[key] == value:
+                return obj
+        return None
+
 
 class JLeaf(namedtuple('JLeaf', ['typ', 'none'])):
     def __new__(cls, typ, none=False):
diff --git a/qa/tasks/mgr/dashboard/test_mgr_module.py b/qa/tasks/mgr/dashboard/test_mgr_module.py
new file mode 100644 (file)
index 0000000..8348476
--- /dev/null
@@ -0,0 +1,154 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import logging
+import requests
+import time
+
+from .helper import DashboardTestCase, JObj, JList, JLeaf
+
+logger = logging.getLogger(__name__)
+
+
+class MgrModuleTestCase(DashboardTestCase):
+    @classmethod
+    def tearDownClass(cls):
+        cls._ceph_cmd(['mgr', 'module', 'disable', 'telemetry'])
+        super(MgrModuleTestCase, cls).tearDownClass()
+
+    def wait_until_rest_api_accessible(self):
+        """
+        Wait until the REST API is accessible.
+        """
+
+        def _check_connection():
+            try:
+                # Try reaching an API endpoint successfully.
+                self._get('/api/mgr/module')
+                if self._resp.status_code == 200:
+                    return True
+            except requests.ConnectionError:
+                pass
+            return False
+
+        self.wait_until_true(_check_connection, timeout=20, period=2)
+
+
+class MgrModuleTest(MgrModuleTestCase):
+    def test_list_disabled_module(self):
+        self._ceph_cmd(['mgr', 'module', 'disable', 'telemetry'])
+        self.wait_until_rest_api_accessible()
+        data = self._get('/api/mgr/module')
+        self.assertStatus(200)
+        self.assertSchema(
+            data,
+            JList(
+                JObj(sub_elems={
+                    'name': JLeaf(str),
+                    'enabled': JLeaf(bool)
+                })))
+        module_info = self.find_object_in_list('name', 'telemetry', data)
+        self.assertIsNotNone(module_info)
+        self.assertFalse(module_info['enabled'])
+
+    def test_list_enabled_module(self):
+        self._ceph_cmd(['mgr', 'module', 'enable', 'telemetry'])
+        self.wait_until_rest_api_accessible()
+        data = self._get('/api/mgr/module')
+        self.assertStatus(200)
+        self.assertSchema(
+            data,
+            JList(
+                JObj(sub_elems={
+                    'name': JLeaf(str),
+                    'enabled': JLeaf(bool)
+                })))
+        module_info = self.find_object_in_list('name', 'telemetry', data)
+        self.assertIsNotNone(module_info)
+        self.assertTrue(module_info['enabled'])
+
+
+class MgrModuleTelemetryTest(MgrModuleTestCase):
+    def test_get(self):
+        data = self._get('/api/mgr/module/telemetry')
+        self.assertStatus(200)
+        self.assertSchema(
+            data,
+            JObj(
+                sub_elems={
+                    'contact': JLeaf(str),
+                    'description': JLeaf(str),
+                    'enabled': JLeaf(bool),
+                    'interval': JLeaf(int),
+                    'leaderboard': JLeaf(bool),
+                    'organization': JLeaf(str),
+                    'proxy': JLeaf(str),
+                    'url': JLeaf(str)
+                }))
+
+    def test_put(self):
+        self.set_config_key('config/mgr/mgr/telemetry/contact', '')
+        self.set_config_key('config/mgr/mgr/telemetry/description', '')
+        self.set_config_key('config/mgr/mgr/telemetry/enabled', 'True')
+        self.set_config_key('config/mgr/mgr/telemetry/interval', '72')
+        self.set_config_key('config/mgr/mgr/telemetry/leaderboard', 'False')
+        self.set_config_key('config/mgr/mgr/telemetry/organization', '')
+        self.set_config_key('config/mgr/mgr/telemetry/proxy', '')
+        self.set_config_key('config/mgr/mgr/telemetry/url', '')
+        self._put(
+            '/api/mgr/module/telemetry',
+            data={
+                'config': {
+                    'contact': 'tux@suse.com',
+                    'description': 'test',
+                    'enabled': False,
+                    'interval': 4711,
+                    'leaderboard': True,
+                    'organization': 'SUSE Linux',
+                    'proxy': 'foo',
+                    'url': 'https://foo.bar/report'
+                }
+            })
+        self.assertStatus(200)
+        data = self._get('/api/mgr/module/telemetry')
+        self.assertStatus(200)
+        self.assertEqual(data['contact'], 'tux@suse.com')
+        self.assertEqual(data['description'], 'test')
+        self.assertFalse(data['enabled'])
+        self.assertEqual(data['interval'], 4711)
+        self.assertTrue(data['leaderboard'])
+        self.assertEqual(data['organization'], 'SUSE Linux')
+        self.assertEqual(data['proxy'], 'foo')
+        self.assertEqual(data['url'], 'https://foo.bar/report')
+
+    def test_enable(self):
+        self._ceph_cmd(['mgr', 'module', 'disable', 'telemetry'])
+        self.wait_until_rest_api_accessible()
+        try:
+            # Note, an exception is thrown because the Ceph Mgr
+            # modules are reloaded.
+            self._post('/api/mgr/module/telemetry/enable')
+        except requests.ConnectionError:
+            pass
+        self.wait_until_rest_api_accessible()
+        data = self._get('/api/mgr/module')
+        self.assertStatus(200)
+        module_info = self.find_object_in_list('name', 'telemetry', data)
+        self.assertIsNotNone(module_info)
+        self.assertTrue(module_info['enabled'])
+
+    def test_disable(self):
+        self._ceph_cmd(['mgr', 'module', 'enable', 'telemetry'])
+        self.wait_until_rest_api_accessible()
+        try:
+            # Note, an exception is thrown because the Ceph Mgr
+            # modules are reloaded.
+            self._post('/api/mgr/module/telemetry/disable')
+        except requests.ConnectionError:
+            pass
+        self.wait_until_rest_api_accessible()
+        data = self._get('/api/mgr/module')
+        self.assertStatus(200)
+        module_info = self.find_object_in_list('name', 'telemetry', data)
+        self.assertIsNotNone(module_info)
+        self.assertFalse(module_info['enabled'])
index eb8cff6cb23869f0a070132d33f9bebf9a0e12f8..b4c0676b0dd6cef7741c59cffdc623b984998cde 100644 (file)
@@ -7,7 +7,6 @@ import six
 
 from .helper import DashboardTestCase, JObj, JList, JLeaf
 
-
 logger = logging.getLogger(__name__)
 
 
@@ -32,23 +31,22 @@ class RgwTestCase(DashboardTestCase):
         # Create a test user?
         if cls.create_test_user:
             cls._radosgw_admin_cmd([
-                'user', 'create', '--uid', 'teuth-test-user',
-                '--display-name', 'teuth-test-user'
+                'user', 'create', '--uid', 'teuth-test-user', '--display-name',
+                'teuth-test-user'
             ])
             cls._radosgw_admin_cmd([
-                'caps', 'add', '--uid', 'teuth-test-user',
-                '--caps', 'metadata=write'
+                'caps', 'add', '--uid', 'teuth-test-user', '--caps',
+                'metadata=write'
             ])
             cls._radosgw_admin_cmd([
-                'subuser', 'create', '--uid', 'teuth-test-user',
-                '--subuser', 'teuth-test-subuser', '--access',
-                'full', '--key-type', 's3', '--access-key',
-                'xyz123'
+                'subuser', 'create', '--uid', 'teuth-test-user', '--subuser',
+                'teuth-test-subuser', '--access', 'full', '--key-type', 's3',
+                '--access-key', 'xyz123'
             ])
             cls._radosgw_admin_cmd([
-                'subuser', 'create', '--uid', 'teuth-test-user',
-                '--subuser', 'teuth-test-subuser2', '--access',
-                'full', '--key-type', 'swift'
+                'subuser', 'create', '--uid', 'teuth-test-user', '--subuser',
+                'teuth-test-subuser2', '--access', 'full', '--key-type',
+                'swift'
             ])
 
     @classmethod
@@ -63,17 +61,6 @@ class RgwTestCase(DashboardTestCase):
     def get_rgw_user(self, uid):
         return self._get('/api/rgw/user/{}'.format(uid))
 
-    def find_in_list(self, key, value, data):
-        """
-        Helper function to find an object with the specified key/value
-        in a list.
-        :param key: The name of the key.
-        :param value: The value to search for.
-        :param data: The list to process.
-        :return: Returns the found object or None.
-        """
-        return next(iter(filter(lambda x: x[key] == value, data)), None)
-
 
 class RgwApiCredentialsTest(RgwTestCase):
 
@@ -135,7 +122,8 @@ class RgwBucketTest(RgwTestCase):
 
     @classmethod
     def tearDownClass(cls):
-        cls._radosgw_admin_cmd(['user', 'rm', '--tenant', 'testx', '--uid=teuth-test-user'])
+        cls._radosgw_admin_cmd(
+            ['user', 'rm', '--tenant', 'testx', '--uid=teuth-test-user'])
         super(RgwBucketTest, cls).tearDownClass()
 
     def test_all(self):
@@ -230,8 +218,8 @@ class RgwBucketTest(RgwTestCase):
         self.assertIn('testx/teuth-test-bucket', data)
 
         # Get the bucket.
-        data = self._get('/api/rgw/bucket/{}'.format(urllib.quote_plus(
-            'testx/teuth-test-bucket')))
+        data = self._get('/api/rgw/bucket/{}'.format(
+            urllib.quote_plus('testx/teuth-test-bucket')))
         self.assertStatus(200)
         self.assertSchema(data, JObj(sub_elems={
             'owner': JLeaf(str),
@@ -246,21 +234,22 @@ class RgwBucketTest(RgwTestCase):
 
         # Update the bucket.
         self._put(
-            '/api/rgw/bucket/{}'.format(urllib.quote_plus('testx/teuth-test-bucket')),
+            '/api/rgw/bucket/{}'.format(
+                urllib.quote_plus('testx/teuth-test-bucket')),
             params={
                 'bucket_id': data['id'],
                 'uid': 'admin'
             })
         self.assertStatus(200)
-        data = self._get('/api/rgw/bucket/{}'.format(urllib.quote_plus(
-            'testx/teuth-test-bucket')))
+        data = self._get('/api/rgw/bucket/{}'.format(
+            urllib.quote_plus('testx/teuth-test-bucket')))
         self.assertStatus(200)
         self.assertIn('owner', data)
         self.assertEqual(data['owner'], 'admin')
 
         # Delete the bucket.
-        self._delete('/api/rgw/bucket/{}'.format(urllib.quote_plus(
-            'testx/teuth-test-bucket')))
+        self._delete('/api/rgw/bucket/{}'.format(
+            urllib.quote_plus('testx/teuth-test-bucket')))
         self.assertStatus(204)
         data = self._get('/api/rgw/bucket')
         self.assertStatus(200)
@@ -271,8 +260,9 @@ class RgwDaemonTest(DashboardTestCase):
 
     AUTH_ROLES = ['rgw-manager']
 
-
-    @DashboardTestCase.RunAs('test', 'test', [{'rgw': ['create', 'update', 'delete']}])
+    @DashboardTestCase.RunAs('test', 'test', [{
+        'rgw': ['create', 'update', 'delete']
+    }])
     def test_read_access_permissions(self):
         self._get('/api/rgw/daemon')
         self.assertStatus(403)
@@ -374,9 +364,7 @@ class RgwUserTest(RgwTestCase):
         # Update the user.
         self._put(
             '/api/rgw/user/teuth-test-user',
-            params={
-                'display_name': 'new name'
-            })
+            params={'display_name': 'new name'})
         self.assertStatus(200)
         data = self.jsonBody()
         self._assert_user_data(data)
@@ -396,10 +384,12 @@ class RgwUserTest(RgwTestCase):
 
     def test_create_get_update_delete_w_tenant(self):
         # Create a new user.
-        self._post('/api/rgw/user', params={
-            'uid': 'test01$teuth-test-user',
-            'display_name': 'display name'
-        })
+        self._post(
+            '/api/rgw/user',
+            params={
+                'uid': 'test01$teuth-test-user',
+                'display_name': 'display name'
+            })
         self.assertStatus(201)
         data = self.jsonBody()
         self._assert_user_data(data)
@@ -417,9 +407,7 @@ class RgwUserTest(RgwTestCase):
         # Update the user.
         self._put(
             '/api/rgw/user/test01$teuth-test-user',
-            params={
-                'display_name': 'new name'
-            })
+            params={'display_name': 'new name'})
         self.assertStatus(200)
         data = self.jsonBody()
         self._assert_user_data(data)
@@ -504,7 +492,7 @@ class RgwUserKeyTest(RgwTestCase):
         data = self.jsonBody()
         self.assertStatus(201)
         self.assertGreaterEqual(len(data), 3)
-        key = self.find_in_list('access_key', 'abc987', data)
+        key = self.find_object_in_list('access_key', 'abc987', data)
         self.assertIsInstance(key, object)
         self.assertEqual(key['secret_key'], 'aaabbbccc')
 
@@ -520,7 +508,7 @@ class RgwUserKeyTest(RgwTestCase):
         data = self.jsonBody()
         self.assertStatus(201)
         self.assertGreaterEqual(len(data), 2)
-        key = self.find_in_list('secret_key', 'xxxyyyzzz', data)
+        key = self.find_object_in_list('secret_key', 'xxxyyyzzz', data)
         self.assertIsInstance(key, object)
 
     def test_delete_s3(self):
@@ -624,14 +612,15 @@ class RgwUserSubuserTest(RgwTestCase):
             })
         self.assertStatus(201)
         data = self.jsonBody()
-        subuser = self.find_in_list('id', 'teuth-test-user:tux', data)
+        subuser = self.find_object_in_list('id', 'teuth-test-user:tux', data)
         self.assertIsInstance(subuser, object)
         self.assertEqual(subuser['permissions'], 'read-write')
 
         # Get the user data to validate the keys.
         data = self.get_rgw_user('teuth-test-user')
         self.assertStatus(200)
-        key = self.find_in_list('user', 'teuth-test-user:tux', data['swift_keys'])
+        key = self.find_object_in_list('user', 'teuth-test-user:tux',
+                                       data['swift_keys'])
         self.assertIsInstance(key, object)
 
     def test_create_s3(self):
@@ -646,14 +635,15 @@ class RgwUserSubuserTest(RgwTestCase):
             })
         self.assertStatus(201)
         data = self.jsonBody()
-        subuser = self.find_in_list('id', 'teuth-test-user:hugo', data)
+        subuser = self.find_object_in_list('id', 'teuth-test-user:hugo', data)
         self.assertIsInstance(subuser, object)
         self.assertEqual(subuser['permissions'], 'write')
 
         # Get the user data to validate the keys.
         data = self.get_rgw_user('teuth-test-user')
         self.assertStatus(200)
-        key = self.find_in_list('user', 'teuth-test-user:hugo', data['keys'])
+        key = self.find_object_in_list('user', 'teuth-test-user:hugo',
+                                       data['keys'])
         self.assertIsInstance(key, object)
         self.assertEqual(key['secret_key'], 'xxx')
 
@@ -665,8 +655,8 @@ class RgwUserSubuserTest(RgwTestCase):
         # Get the user data to check that the keys don't exist anymore.
         data = self.get_rgw_user('teuth-test-user')
         self.assertStatus(200)
-        key = self.find_in_list('user', 'teuth-test-user:teuth-test-subuser2',
-                                data['swift_keys'])
+        key = self.find_object_in_list(
+            'user', 'teuth-test-user:teuth-test-subuser2', data['swift_keys'])
         self.assertIsNone(key)
 
     def test_delete_wo_purge(self):
@@ -678,6 +668,6 @@ class RgwUserSubuserTest(RgwTestCase):
         # Get the user data to check whether they keys still exist.
         data = self.get_rgw_user('teuth-test-user')
         self.assertStatus(200)
-        key = self.find_in_list('user', 'teuth-test-user:teuth-test-subuser',
-                                data['keys'])
+        key = self.find_object_in_list(
+            'user', 'teuth-test-user:teuth-test-subuser', data['keys'])
         self.assertIsInstance(key, object)
diff --git a/src/pybind/mgr/dashboard/controllers/mgr_modules.py b/src/pybind/mgr/dashboard/controllers/mgr_modules.py
new file mode 100644 (file)
index 0000000..26eeebd
--- /dev/null
@@ -0,0 +1,129 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+from . import ApiController, RESTController
+from .. import mgr
+from ..security import Scope
+from ..services.ceph_service import CephService
+from ..services.exception import handle_send_command_error
+from ..tools import find_object_in_list, str_to_bool
+
+
+@ApiController('/mgr/module', Scope.CONFIG_OPT)
+class MgrModules(RESTController):
+    managed_modules = ['telemetry']
+
+    def list(self):
+        """
+        Get the list of managed modules.
+        :return: A list of objects with the fields 'name' and 'enabled'.
+        :rtype: list
+        """
+        result = []
+        mgr_map = mgr.get('mgr_map')
+        for module_config in mgr_map['available_modules']:
+            if self._is_module_managed(module_config['name']):
+                result.append({'name': module_config['name'], 'enabled': False})
+        for name in mgr_map['modules']:
+            if self._is_module_managed(name):
+                obj = find_object_in_list('name', name, result)
+                obj['enabled'] = True
+        return result
+
+    def get(self, module_name):
+        """
+        Retrieve the values of the persistent configuration settings.
+        :param module_name: The name of the Ceph Mgr module.
+        :type module_name: str
+        :return: The values of the module options.
+        :rtype: dict
+        """
+        assert self._is_module_managed(module_name)
+        options = self._get_module_options(module_name)
+        result = {}
+        for name, option in options.items():
+            result[name] = mgr.get_module_option_ex(module_name, name,
+                                                    option['default_value'])
+        return result
+
+    @RESTController.Resource('PUT')
+    def set(self, module_name, config):
+        """
+        Set the values of the persistent configuration settings.
+        :param module_name: The name of the Ceph Mgr module.
+        :type module_name: str
+        :param config: The values of the module options to be stored.
+        :type config: dict
+        """
+        assert self._is_module_managed(module_name)
+        options = self._get_module_options(module_name)
+        for name in options.keys():
+            if name in config:
+                mgr.set_module_option_ex(module_name, name, config[name])
+
+    @RESTController.Resource('POST')
+    @handle_send_command_error('mgr_modules')
+    def enable(self, module_name):
+        """
+        Enable the specified Ceph Mgr module.
+        """
+        assert self._is_module_managed(module_name)
+        CephService.send_command(
+            'mon', 'mgr module enable', module=module_name)
+
+    @RESTController.Resource('POST')
+    @handle_send_command_error('mgr_modules')
+    def disable(self, module_name):
+        """
+        Disable the specified Ceph Mgr module.
+        """
+        assert self._is_module_managed(module_name)
+        CephService.send_command(
+            'mon', 'mgr module disable', module=module_name)
+
+    def _is_module_managed(self, module_name):
+        """
+        Check if the specified Ceph Mgr module is managed by this service.
+        :param module_name: The name of the Ceph Mgr module.
+        :type module_name: str
+        :return: Returns ``true`` if the Ceph Mgr module is managed by
+            this service, otherwise ``false``.
+        :rtype: bool
+        """
+        return module_name in self.managed_modules
+
+    def _get_module_config(self, module_name):
+        """
+        Helper function to get detailed module configuration.
+        :param module_name: The name of the Ceph Mgr module.
+        :type module_name: str
+        :return: The module information, e.g. module name, can run,
+            error string and available module options.
+        :rtype: dict or None
+        """
+        mgr_map = mgr.get('mgr_map')
+        return find_object_in_list('name', module_name,
+                                   mgr_map['available_modules'])
+
+    def _get_module_options(self, module_name):
+        """
+        Helper function to get the module options.
+        :param module_name: The name of the Ceph Mgr module.
+        :type module_name: str
+        :return: The module options.
+        :rtype: dict
+        """
+        options = self._get_module_config(module_name)['module_options']
+        # Workaround a possible bug in the Ceph Mgr implementation. The
+        # 'default_value' field is always returned as a string.
+        for option in options.values():
+            if option['type'] == 'str':
+                if option['default_value'] == 'None':
+                    option['default_value'] = ''
+            elif option['type'] == 'bool':
+                option['default_value'] = str_to_bool(option['default_value'])
+            elif option['type'] == 'float':
+                option['default_value'] = float(option['default_value'])
+            elif option['type'] in ['uint', 'int', 'size', 'secs']:
+                option['default_value'] = int(option['default_value'])
+        return options
index 70e9eaed8f3336e79d74ed098678fede37bf06d9..295abddb1467cae503207d3e482facf7f2a33f24 100644 (file)
       "integrity": "sha512-MFh0d/Wa7vkKO3Y3LlacqAEeHK0mckVqzDieUKTT+KGxi+zIpeVsFxymkIiRpbpDziHc290Xr9A1O4Om7otoRA==",
       "dev": true
     },
+    "ng-block-ui": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/ng-block-ui/-/ng-block-ui-2.1.0.tgz",
+      "integrity": "sha512-bjc2ZuizMdSp1eJDqA8+1jCF2ORQ2aooffchhRkpkjwZPPBkoFs5v7NEM+O3KieIr+4xegAl2LAyP7wu1JeWAA==",
+      "requires": {
+        "tslib": "^1.9.0"
+      }
+    },
     "ng2-charts": {
       "version": "1.6.0",
       "resolved": "https://registry.npmjs.org/ng2-charts/-/ng2-charts-1.6.0.tgz",
index b2fa350ea2c394944ac0627fa3039653286ddfa4..527642e93c987063637f84acadbbc55233529bcb 100644 (file)
@@ -65,6 +65,7 @@
     "fork-awesome": "1.1.5",
     "lodash": "4.17.11",
     "moment": "2.23.0",
+    "ng-block-ui": "^2.1.0",
     "ng2-charts": "1.6.0",
     "ng2-toastr": "zzakir/ng2-toastr#0eafd72",
     "ng2-tree": "2.0.0-rc.11",
index 908e398398c6a4f011bdcaae40d64f8313c2bd28..f8ef7b6b568bc15be2634c6d6c32988f11d71c9f 100644 (file)
@@ -33,6 +33,8 @@ import { SsoNotFoundComponent } from './core/auth/sso/sso-not-found/sso-not-foun
 import { UserFormComponent } from './core/auth/user-form/user-form.component';
 import { UserListComponent } from './core/auth/user-list/user-list.component';
 import { ForbiddenComponent } from './core/forbidden/forbidden.component';
+import { MgrModulesListComponent } from './core/mgr-modules/mgr-modules-list/mgr-modules-list.component';
+import { TelemetryComponent } from './core/mgr-modules/telemetry/telemetry.component';
 import { NotFoundComponent } from './core/not-found/not-found.component';
 import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs';
 import { AuthGuardService } from './shared/services/auth-guard.service';
@@ -128,6 +130,17 @@ const routes: Routes = [
       breadcrumbs: PerformanceCounterBreadcrumbsResolver
     }
   },
+  // Mgr modules
+  {
+    path: 'mgr-modules',
+    canActivate: [AuthGuardService],
+    canActivateChild: [AuthGuardService],
+    data: { breadcrumbs: 'Cluster/Manager Modules' },
+    children: [
+      { path: '', component: MgrModulesListComponent },
+      { path: 'edit/telemetry', component: TelemetryComponent, data: { breadcrumbs: 'Telemetry' } }
+    ]
+  },
   // Pools
   {
     path: 'pool',
index f21c571da6370f7394bccc7cc48f5345c8db2f86..23b65ac2a8c01e1212b30411efefc36f0c83cc07 100644 (file)
@@ -1,6 +1,8 @@
-<cd-navigation *ngIf="!isLoginActive()"></cd-navigation>
-<div class="container-fluid"
-     [ngClass]="{'full-height':isLoginActive(), 'dashboard':isDashboardPage()} ">
-  <cd-breadcrumbs></cd-breadcrumbs>
-  <router-outlet></router-outlet>
-</div>
+<block-ui>
+  <cd-navigation *ngIf="!isLoginActive()"></cd-navigation>
+  <div class="container-fluid"
+       [ngClass]="{'full-height':isLoginActive(), 'dashboard':isDashboardPage()} ">
+    <cd-breadcrumbs></cd-breadcrumbs>
+    <router-outlet></router-outlet>
+  </div>
+<block-ui>
index 058b4788cb6e1664e84d9d646b9d799547aa2e47..920069ab0bf3a9b00a2cc0a4e3295d947ad6a75f 100644 (file)
@@ -6,6 +6,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 
 import { JwtModule } from '@auth0/angular-jwt';
 import { I18n } from '@ngx-translate/i18n-polyfill';
+import { BlockUIModule } from 'ng-block-ui';
 import { ToastModule, ToastOptions } from 'ng2-toastr/ng2-toastr';
 import { AccordionModule } from 'ngx-bootstrap/accordion';
 import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
@@ -37,6 +38,7 @@ registerLocaleData(LocaleHelper.getLocaleData(), LocaleHelper.getLocale());
   declarations: [AppComponent],
   imports: [
     HttpClientModule,
+    BlockUIModule.forRoot(),
     BrowserModule,
     BrowserAnimationsModule,
     ToastModule.forRoot(),
index 0190e52e22167e14bec33f8898c8d495c2e13155..5d61b6909a1787c4d1c6bcf4a27898e3852b55e1 100644 (file)
@@ -3,11 +3,12 @@ import { NgModule } from '@angular/core';
 
 import { AuthModule } from './auth/auth.module';
 import { ForbiddenComponent } from './forbidden/forbidden.component';
+import { MgrModulesModule } from './mgr-modules/mgr-modules.module';
 import { NavigationModule } from './navigation/navigation.module';
 import { NotFoundComponent } from './not-found/not-found.component';
 
 @NgModule({
-  imports: [CommonModule, NavigationModule, AuthModule],
+  imports: [CommonModule, NavigationModule, AuthModule, MgrModulesModule],
   exports: [NavigationModule],
   declarations: [NotFoundComponent, ForbiddenComponent]
 })
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.html
new file mode 100644 (file)
index 0000000..d0efeae
--- /dev/null
@@ -0,0 +1,15 @@
+<cd-table #table
+          [autoReload]="false"
+          [data]="modules"
+          [columns]="columns"
+          columnMode="flex"
+          selectionType="multi"
+          (updateSelection)="updateSelection($event)"
+          identifier="module"
+          (fetchData)="getModuleList($event)">
+  <cd-table-actions class="table-actions"
+                    [permission]="permission"
+                    [selection]="selection"
+                    [tableActions]="tableActions">
+  </cd-table-actions>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.spec.ts
new file mode 100644 (file)
index 0000000..3739b8f
--- /dev/null
@@ -0,0 +1,90 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+
+import {
+  configureTestBed,
+  i18nProviders,
+  PermissionHelper
+} from '../../../../testing/unit-test-helper';
+import { TableActionsComponent } from '../../../shared/datatable/table-actions/table-actions.component';
+import { SharedModule } from '../../../shared/shared.module';
+import { MgrModulesListComponent } from './mgr-modules-list.component';
+
+describe('MgrModulesListComponent', () => {
+  let component: MgrModulesListComponent;
+  let fixture: ComponentFixture<MgrModulesListComponent>;
+
+  configureTestBed({
+    declarations: [MgrModulesListComponent],
+    imports: [RouterTestingModule, SharedModule, HttpClientTestingModule, ToastModule.forRoot()],
+    providers: i18nProviders
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(MgrModulesListComponent);
+    component = fixture.componentInstance;
+  });
+
+  it('should create', () => {
+    fixture.detectChanges();
+    expect(component).toBeTruthy();
+  });
+
+  describe('show action buttons and drop down actions depending on permissions', () => {
+    let tableActions: TableActionsComponent;
+    let scenario: { fn; empty; single };
+    let permissionHelper: PermissionHelper;
+
+    const getTableActionComponent = (): TableActionsComponent => {
+      fixture.detectChanges();
+      return fixture.debugElement.query(By.directive(TableActionsComponent)).componentInstance;
+    };
+
+    beforeEach(() => {
+      permissionHelper = new PermissionHelper(component.permission, () =>
+        getTableActionComponent()
+      );
+      scenario = {
+        fn: () => tableActions.getCurrentButton().name,
+        single: 'Edit',
+        empty: 'Edit'
+      };
+    });
+
+    describe('with read and update', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(0, 1, 0);
+      });
+
+      it('shows action button', () => permissionHelper.testScenarios(scenario));
+
+      it('shows all actions', () => {
+        expect(tableActions.tableActions.length).toBe(3);
+        expect(tableActions.tableActions).toEqual(component.tableActions);
+      });
+    });
+
+    describe('with only read', () => {
+      beforeEach(() => {
+        tableActions = permissionHelper.setPermissionsAndGetActions(0, 0, 0);
+      });
+
+      it('shows no main action', () => {
+        permissionHelper.testScenarios({
+          fn: () => tableActions.getCurrentButton(),
+          single: undefined,
+          empty: undefined
+        });
+      });
+
+      it('shows no actions', () => {
+        expect(tableActions.tableActions.length).toBe(0);
+        expect(tableActions.tableActions).toEqual([]);
+      });
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts
new file mode 100644 (file)
index 0000000..94c7cf4
--- /dev/null
@@ -0,0 +1,163 @@
+import { Component, ViewChild } from '@angular/core';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import { BlockUI, NgBlockUI } from 'ng-block-ui';
+import { timer as observableTimer } from 'rxjs';
+
+import { MgrModuleService } from '../../../shared/api/mgr-module.service';
+import { TableComponent } from '../../../shared/datatable/table/table.component';
+import { CellTemplate } from '../../../shared/enum/cell-template.enum';
+import { CdTableAction } from '../../../shared/models/cd-table-action';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { Permission } from '../../../shared/models/permissions';
+import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { NotificationService } from '../../../shared/services/notification.service';
+
+@Component({
+  selector: 'cd-mgr-modules-list',
+  templateUrl: './mgr-modules-list.component.html',
+  styleUrls: ['./mgr-modules-list.component.scss']
+})
+export class MgrModulesListComponent {
+  @ViewChild(TableComponent)
+  table: TableComponent;
+  @BlockUI()
+  blockUI: NgBlockUI;
+
+  permission: Permission;
+  tableActions: CdTableAction[];
+  columns: CdTableColumn[] = [];
+  modules: object[] = [];
+  selection: CdTableSelection = new CdTableSelection();
+
+  constructor(
+    private authStorageService: AuthStorageService,
+    private mgrModuleService: MgrModuleService,
+    private notificationService: NotificationService,
+    private i18n: I18n
+  ) {
+    this.permission = this.authStorageService.getPermissions().configOpt;
+    this.columns = [
+      {
+        name: this.i18n('Name'),
+        prop: 'name',
+        flexGrow: 1
+      },
+      {
+        name: this.i18n('Enabled'),
+        prop: 'enabled',
+        flexGrow: 1,
+        cellTransformation: CellTemplate.checkIcon
+      }
+    ];
+    const getModuleUri = () =>
+      this.selection.first() && encodeURIComponent(this.selection.first().name);
+    this.tableActions = [
+      {
+        permission: 'update',
+        icon: 'fa-pencil',
+        routerLink: () => `/mgr-modules/edit/${getModuleUri()}`,
+        name: this.i18n('Edit')
+      },
+      {
+        name: this.i18n('Enable'),
+        permission: 'update',
+        click: () => this.updateModuleState(),
+        disable: () => this.isTableActionDisabled('enabled'),
+        icon: 'fa-play'
+      },
+      {
+        name: this.i18n('Disable'),
+        permission: 'update',
+        click: () => this.updateModuleState(),
+        disable: () => this.isTableActionDisabled('disabled'),
+        icon: 'fa-stop'
+      }
+    ];
+  }
+
+  getModuleList(context: CdTableFetchDataContext) {
+    this.mgrModuleService.list().subscribe(
+      (resp: object[]) => {
+        this.modules = resp;
+      },
+      () => {
+        context.error();
+      }
+    );
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  /**
+   * Check if the table action is disabled.
+   * @param state The expected module state, e.g. ``enabled`` or ``disabled``.
+   * @returns If the specified state is validated to true or no selection is
+   *   done, then ``true`` is returned, otherwise ``false``.
+   */
+  isTableActionDisabled(state: 'enabled' | 'disabled') {
+    if (!this.selection.hasSelection) {
+      return true;
+    }
+    switch (state) {
+      case 'enabled':
+        return this.selection.first().enabled;
+      case 'disabled':
+        return !this.selection.first().enabled;
+    }
+  }
+
+  /**
+   * Update the Ceph Mgr module state to enabled or disabled.
+   */
+  updateModuleState() {
+    if (!this.selection.hasSelection) {
+      return;
+    }
+
+    let $obs;
+    const fnWaitUntilReconnected = () => {
+      observableTimer(2000).subscribe(() => {
+        // Trigger an API request to check if the connection is
+        // re-established.
+        this.mgrModuleService.list().subscribe(
+          () => {
+            // Resume showing the notification toasties.
+            this.notificationService.suspendToasties(false);
+            // Unblock the whole UI.
+            this.blockUI.stop();
+            // Reload the data table content.
+            this.table.refreshBtn();
+          },
+          () => {
+            fnWaitUntilReconnected();
+          }
+        );
+      });
+    };
+
+    // Note, the Ceph Mgr is always restarted when a module
+    // is enabled/disabled.
+    const module = this.selection.first();
+    if (module.enabled) {
+      $obs = this.mgrModuleService.disable(module.name);
+    } else {
+      $obs = this.mgrModuleService.enable(module.name);
+    }
+    $obs.subscribe(
+      () => {},
+      () => {
+        // Suspend showing the notification toasties.
+        this.notificationService.suspendToasties(true);
+        // Block the whole UI to prevent user interactions until
+        // the connection to the backend is reestablished
+        this.blockUI.start(this.i18n('Reconnecting, please wait ...'));
+        fnWaitUntilReconnected();
+      }
+    );
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules.module.ts
new file mode 100644 (file)
index 0000000..e101fef
--- /dev/null
@@ -0,0 +1,14 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { AppRoutingModule } from '../../app-routing.module';
+import { SharedModule } from '../../shared/shared.module';
+import { MgrModulesListComponent } from './mgr-modules-list/mgr-modules-list.component';
+import { TelemetryComponent } from './telemetry/telemetry.component';
+
+@NgModule({
+  imports: [CommonModule, ReactiveFormsModule, SharedModule, AppRoutingModule],
+  declarations: [TelemetryComponent, MgrModulesListComponent]
+})
+export class MgrModulesModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.html
new file mode 100644 (file)
index 0000000..58e4c07
--- /dev/null
@@ -0,0 +1,144 @@
+<cd-loading-panel *ngIf="loading && !error"
+                  i18n>Loading configuration...</cd-loading-panel>
+<cd-error-panel *ngIf="loading && error"
+                i18n>The configuration could not be loaded.</cd-error-panel>
+
+<div class="col-sm-12 col-lg-6"
+     *ngIf="!loading && !error">
+  <form name="telemetryForm"
+        class="form-horizontal"
+        #frm="ngForm"
+        [formGroup]="telemetryForm"
+        novalidate>
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title" i18n>Telemetry</h3>
+      </div>
+      <div class="panel-body">
+
+        <!-- Enabled -->
+        <div class="form-group">
+          <div class="col-sm-offset-3 col-sm-9">
+            <div class="checkbox checkbox-primary">
+              <input id="enabled"
+                     type="checkbox"
+                     formControlName="enabled">
+              <label for="enabled"
+                     i18n>Enabled</label>
+            </div>
+          </div>
+        </div>
+
+        <!-- Leaderboard -->
+        <div class="form-group">
+          <div class="col-sm-offset-3 col-sm-9">
+            <div class="checkbox checkbox-primary">
+              <input id="leaderboard"
+                     type="checkbox"
+                     formControlName="leaderboard">
+              <label for="leaderboard"
+                     i18n>Leaderboard</label>
+            </div>
+          </div>
+        </div>
+
+        <!-- Contact -->
+        <div class="form-group"
+             [ngClass]="{'has-error': telemetryForm.showError('contact', frm)}">
+          <label class="control-label col-sm-3"
+                 for="contact"
+                 i18n>Contact</label>
+          <div class="col-sm-9">
+            <input id="contact"
+                   class="form-control"
+                   type="text"
+                   formControlName="contact">
+            <span class="help-block"
+                  *ngIf="telemetryForm.showError('contact', frm, 'email')"
+                  i18n>This is not a valid email address.</span>
+          </div>
+        </div>
+
+        <!-- Organization -->
+        <div class="form-group">
+          <label class="control-label col-sm-3"
+                 for="organization">
+            <ng-container i18n>Organization</ng-container>
+          </label>
+          <div class="col-sm-9">
+            <input id="organization"
+                   class="form-control"
+                   type="text"
+                   formControlName="organization">
+          </div>
+        </div>
+
+        <!-- Description -->
+        <div class="form-group">
+          <label class="control-label col-sm-3"
+                 for="organization">
+            <ng-container i18n>Description</ng-container>
+          </label>
+          <div class="col-sm-9">
+            <input id="description"
+                   class="form-control"
+                   type="text"
+                   formControlName="description">
+          </div>
+        </div>
+
+        <!-- Proxy -->
+        <div class="form-group">
+          <label class="control-label col-sm-3"
+                 for="proxy">
+            <ng-container i18n>Proxy</ng-container>
+          </label>
+          <div class="col-sm-9">
+            <input id="proxy"
+                   class="form-control"
+                   type="text"
+                   formControlName="proxy">
+          </div>
+        </div>
+
+        <!-- Interval -->
+        <div class="form-group"
+             [ngClass]="{'has-error': telemetryForm.showError('interval', frm)}">
+          <label class="control-label col-sm-3"
+                 for="interval">
+            <ng-container i18n>Interval</ng-container>
+          </label>
+          <div class="col-sm-9">
+            <input id="interval"
+                   class="form-control"
+                   type="number"
+                   formControlName="interval">
+            <span class="help-block"
+                  *ngIf="telemetryForm.showError('interval', frm, 'required')"
+                  i18n>This field is required.</span>
+            <span class="help-block"
+                  *ngIf="telemetryForm.showError('interval', frm, 'min')"
+                  i18n>The entered value must be at least 24 hours.</span>
+            <span class="help-block"
+                  *ngIf="telemetryForm.showError('interval', frm, 'pattern')"
+                  i18n>The entered value needs to be a number.</span>
+          </div>
+        </div>
+
+      </div>
+      <div class="panel-footer">
+        <div class="button-group text-right">
+          <cd-submit-button type="button"
+                            (submitAction)="onSubmit()"
+                            [form]="telemetryForm">
+            <ng-container i18n>Update</ng-container>
+          </cd-submit-button>
+          <button i18n
+                  type="button"
+                  class="btn btn-sm btn-default"
+                  routerLink="/mgr-modules">Back</button>
+        </div>
+      </div>
+    </div>
+  </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.spec.ts
new file mode 100644 (file)
index 0000000..63f7766
--- /dev/null
@@ -0,0 +1,28 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { SharedModule } from '../../../shared/shared.module';
+import { TelemetryComponent } from './telemetry.component';
+
+describe('TelemetryComponent', () => {
+  let component: TelemetryComponent;
+  let fixture: ComponentFixture<TelemetryComponent>;
+
+  configureTestBed({
+    declarations: [TelemetryComponent],
+    imports: [HttpClientTestingModule, ReactiveFormsModule, RouterTestingModule, SharedModule]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(TelemetryComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.ts
new file mode 100644 (file)
index 0000000..0245d2d
--- /dev/null
@@ -0,0 +1,86 @@
+import { Component, OnInit } from '@angular/core';
+import { Validators } from '@angular/forms';
+import { Router } from '@angular/router';
+
+import { MgrModuleService } from '../../../shared/api/mgr-module.service';
+import { CdFormBuilder } from '../../../shared/forms/cd-form-builder';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { CdValidators } from '../../../shared/forms/cd-validators';
+
+@Component({
+  selector: 'cd-telemetry',
+  templateUrl: './telemetry.component.html',
+  styleUrls: ['./telemetry.component.scss']
+})
+export class TelemetryComponent implements OnInit {
+  telemetryForm: CdFormGroup;
+  error = false;
+  loading = false;
+
+  constructor(
+    private router: Router,
+    private formBuilder: CdFormBuilder,
+    private mgrModuleService: MgrModuleService
+  ) {
+    this.createForm();
+  }
+
+  createForm() {
+    this.telemetryForm = this.formBuilder.group({
+      enabled: [false],
+      leaderboard: [false],
+      contact: [null, [CdValidators.email]],
+      organization: [null, [Validators.maxLength(256)]],
+      description: [null, [Validators.maxLength(256)]],
+      proxy: [null],
+      interval: [72, [Validators.min(24), CdValidators.number(), Validators.required]],
+      url: [null]
+    });
+  }
+
+  ngOnInit() {
+    this.loading = true;
+    this.mgrModuleService.getConfig('telemetry').subscribe(
+      (resp: object) => {
+        this.loading = false;
+        this.telemetryForm.setValue(resp);
+      },
+      (error) => {
+        this.error = error;
+      }
+    );
+  }
+
+  goToListView() {
+    this.router.navigate(['/mgr-modules']);
+  }
+
+  onSubmit() {
+    // Exit immediately if the form isn't dirty.
+    if (this.telemetryForm.pristine) {
+      this.goToListView();
+    }
+    const config = {};
+    const fieldNames = [
+      'enabled',
+      'leaderboard',
+      'contact',
+      'organization',
+      'description',
+      'proxy',
+      'interval'
+    ];
+    fieldNames.forEach((fieldName) => {
+      config[fieldName] = this.telemetryForm.getValue(fieldName);
+    });
+    this.mgrModuleService.updateConfig('telemetry', config).subscribe(
+      () => {
+        this.goToListView();
+      },
+      () => {
+        // Reset the 'Submit' button.
+        this.telemetryForm.setErrors({ cdSubmitButton: true });
+      }
+    );
+  }
+}
index a77c7d370356001dd40ba94be81e55437cb4aa78..52ac52a110d85374cf18df213eff3cd29a546810 100644 (file)
                class="dropdown-item"
                routerLink="/crush-map">CRUSH map</a>
           </li>
+          <li routerLinkActive="active"
+              class="tc_submenuitem tc_submenuitem_modules"
+              *ngIf="permissions.configOpt.read">
+            <a i18n
+               class="dropdown-item"
+               routerLink="/mgr-modules">Manager Modules</a>
+          </li>
           <li routerLinkActive="active"
               class="tc_submenuitem tc_submenuitem_log"
               *ngIf="permissions.log.read">
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts
new file mode 100644 (file)
index 0000000..f285915
--- /dev/null
@@ -0,0 +1,60 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '../../../testing/unit-test-helper';
+import { MgrModuleService } from './mgr-module.service';
+
+describe('MgrModuleService', () => {
+  let service: MgrModuleService;
+  let httpTesting: HttpTestingController;
+
+  configureTestBed({
+    imports: [HttpClientTestingModule],
+    providers: [MgrModuleService]
+  });
+
+  beforeEach(() => {
+    service = TestBed.get(MgrModuleService);
+    httpTesting = TestBed.get(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpTesting.verify();
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('should call list', () => {
+    service.list().subscribe();
+    const req = httpTesting.expectOne('api/mgr/module');
+    expect(req.request.method).toBe('GET');
+  });
+
+  it('should call getConfig', () => {
+    service.getConfig('foo').subscribe();
+    const req = httpTesting.expectOne('api/mgr/module/foo');
+    expect(req.request.method).toBe('GET');
+  });
+
+  it('should call updateConfig', () => {
+    const config = { foo: 'bar' };
+    service.updateConfig('xyz', config).subscribe();
+    const req = httpTesting.expectOne('api/mgr/module/xyz');
+    expect(req.request.method).toBe('PUT');
+    expect(req.request.body.config).toEqual(config);
+  });
+
+  it('should call enable', () => {
+    service.enable('foo').subscribe();
+    const req = httpTesting.expectOne('api/mgr/module/foo/enable');
+    expect(req.request.method).toBe('POST');
+  });
+
+  it('should call disable', () => {
+    service.disable('bar').subscribe();
+    const req = httpTesting.expectOne('api/mgr/module/bar/disable');
+    expect(req.request.method).toBe('POST');
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts
new file mode 100644 (file)
index 0000000..f5d68ca
--- /dev/null
@@ -0,0 +1,56 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { ApiModule } from './api.module';
+
+@Injectable({
+  providedIn: ApiModule
+})
+export class MgrModuleService {
+  private url = 'api/mgr/module';
+
+  constructor(private http: HttpClient) {}
+
+  /**
+   * Get the list of Ceph Mgr modules and their state (enabled/disabled).
+   * @return {Observable<Object[]>}
+   */
+  list() {
+    return this.http.get(`${this.url}`);
+  }
+
+  /**
+   * Get the Ceph Mgr module configuration.
+   * @param {string} module The name of the mgr module.
+   * @return {Observable<Object>}
+   */
+  getConfig(module: string) {
+    return this.http.get(`${this.url}/${module}`);
+  }
+
+  /**
+   * Update the Ceph Mgr module configuration.
+   * @param {string} module The name of the mgr module.
+   * @param {object} config The configuration.
+   * @return {Observable<Object>}
+   */
+  updateConfig(module: string, config: Object) {
+    return this.http.put(`${this.url}/${module}`, { config: config });
+  }
+
+  /**
+   * Enable the Ceph Mgr module.
+   * @param {string} module The name of the mgr module.
+   */
+  enable(module: string) {
+    return this.http.post(`${this.url}/${module}/enable`, null);
+  }
+
+  /**
+   * Disable the Ceph Mgr module.
+   * @param {string} module The name of the mgr module.
+   */
+  disable(module: string) {
+    return this.http.post(`${this.url}/${module}/disable`, null);
+  }
+}
index 18738f69350ccd268c8ae9eb359a25d96be9ba1b..0612d2f7f28462a1738e5799bdb2770e7d02706d 100644 (file)
@@ -15,6 +15,8 @@ import { TaskMessageService } from './task-message.service';
   providedIn: ServicesModule
 })
 export class NotificationService {
+  private hideToasties = false;
+
   // Observable sources
   private dataSource = new BehaviorSubject<CdNotification[]>([]);
   private queuedNotifications: CdNotificationConfig[] = [];
@@ -128,6 +130,10 @@ export class NotificationService {
   }
 
   private showToasty(notification: CdNotification) {
+    // Exit immediately if no toasty should be displayed.
+    if (this.hideToasties) {
+      return;
+    }
     this.toastr[['error', 'info', 'success'][notification.type]](
       (notification.message ? notification.message + '<br>' : '') +
         this.renderTimeAndApplicationHtml(notification),
@@ -168,4 +174,12 @@ export class NotificationService {
   cancel(timeoutId) {
     window.clearTimeout(timeoutId);
   }
+
+  /**
+   * Suspend showing the notification toasties.
+   * @param {boolean} suspend Set to ``true`` to disable/hide toasties.
+   */
+  suspendToasties(suspend: boolean) {
+    this.hideToasties = suspend;
+  }
 }
index e515f5f35c763db1db03bc21918392633fbb88a0..88af04bd3bbf6b1e670da8e207d2e40564debd8f 100644 (file)
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
           <context context-type="linenumber">85</context>
         </context-group>
+      </trans-unit><trans-unit id="e7b2bc4b86de6e96d353f6bf2b4d793ea3a19ba0" datatype="html">
+        <source>Manager Modules</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
+          <context context-type="linenumber">92</context>
+        </context-group>
       </trans-unit><trans-unit id="eb3d5aefff38a814b76da74371cbf02c0789a1ef" datatype="html">
         <source>Logs</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">92</context>
+          <context context-type="linenumber">99</context>
         </context-group>
       </trans-unit><trans-unit id="9fe218829514884cdd0ca2300573a4e0428c324f" datatype="html">
         <source>Alerts</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">98</context>
+          <context context-type="linenumber">105</context>
         </context-group>
       </trans-unit><trans-unit id="92899fa68e8ca108912163ff58edc8540e453787" datatype="html">
         <source>Pools</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">108</context>
+          <context context-type="linenumber">115</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/mirroring/overview/overview.component.html</context>
         <source>Block</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">122</context>
+          <context context-type="linenumber">129</context>
         </context-group>
       </trans-unit><trans-unit id="b73f7f5060fb22a1e9ec462b1bb02493fa3ab866" datatype="html">
         <source>Images</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">131</context>
+          <context context-type="linenumber">138</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/iscsi-target-form/iscsi-target-form.component.html</context>
         <source>Mirroring</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">139</context>
+          <context context-type="linenumber">146</context>
         </context-group>
       </trans-unit><trans-unit id="811c241d56601b91ef26735b770e64428089b950" datatype="html">
         <source>iSCSI</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">151</context>
+          <context context-type="linenumber">158</context>
         </context-group>
       </trans-unit><trans-unit id="a4eff72d97b7ced051398d581f10968218057ddc" datatype="html">
         <source>Filesystems</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">162</context>
+          <context context-type="linenumber">169</context>
         </context-group>
       </trans-unit><trans-unit id="2190548d236ca5f7bc7ab2bca334b860c5ff2ad4" datatype="html">
         <source>Object Gateway</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">173</context>
+          <context context-type="linenumber">180</context>
         </context-group>
       </trans-unit><trans-unit id="9e24f9e2d42104ffc01599db4d566d1cc518f9e6" datatype="html">
         <source>Daemons</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">182</context>
+          <context context-type="linenumber">189</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/iscsi/iscsi.component.html</context>
         <source>Users</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">188</context>
+          <context context-type="linenumber">195</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/auth/user-tabs/user-tabs.component.html</context>
         <source>Buckets</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
-          <context context-type="linenumber">194</context>
+          <context context-type="linenumber">201</context>
         </context-group>
       </trans-unit><trans-unit id="797f8214e8148f4bf0d244baaa7341706b419549" datatype="html">
         <source>Retrieving data<x id="START_TAG_SPAN_1" ctype="x-span" equiv-text="&lt;span&gt;"/> for
           <context context-type="sourcefile">app/core/auth/user-form/user-form.component.html</context>
           <context context-type="linenumber">151</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">139</context>
+        </context-group>
       </trans-unit><trans-unit id="3fb5709e10166cbc85970cbff103db227dbeb813" datatype="html">
         <source>Select a Language</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/core/auth/user-form/user-form.component.html</context>
           <context context-type="linenumber">87</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">118</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.html</context>
           <context context-type="linenumber">36</context>
           <context context-type="sourcefile">app/core/auth/role-form/role-form.component.html</context>
           <context context-type="linenumber">46</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">80</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/cluster/configuration/configuration-details/configuration-details.component.html</context>
           <context context-type="linenumber">13</context>
           <context context-type="sourcefile">app/ceph/rgw/rgw-user-form/rgw-user-form.component.html</context>
           <context context-type="linenumber">79</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">58</context>
+        </context-group>
       </trans-unit><trans-unit id="ca271adf154956b8fcb28f4f50a37acb3057ff7c" datatype="html">
         <source>The chosen email address is already in use.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/rgw/rgw-user-form/rgw-user-form.component.html</context>
           <context context-type="linenumber">506</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">27</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html</context>
           <context context-type="linenumber">71</context>
           <context context-type="sourcefile">app/core/forbidden/forbidden.component.html</context>
           <context context-type="linenumber">7</context>
         </context-group>
+      </trans-unit><trans-unit id="2447796ddbda942f4e2c46619cb84d69f066e568" datatype="html">
+        <source>Loading configuration...</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">2</context>
+        </context-group>
+      </trans-unit><trans-unit id="b42c0b347a841bed8859ee83de05080ee28c803b" datatype="html">
+        <source>The configuration could not be loaded.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">4</context>
+        </context-group>
+      </trans-unit><trans-unit id="30b9c9863aac7b725b5c028b67c217981474aab4" datatype="html">
+        <source>Telemetry</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">15</context>
+        </context-group>
+      </trans-unit><trans-unit id="49c5da085adc2bdeef21fe3d6739ac7a9403668a" datatype="html">
+        <source>Leaderboard</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">40</context>
+        </context-group>
+      </trans-unit><trans-unit id="34746fb1c7f3d2194d99652bdff89e6e14c9c4f4" datatype="html">
+        <source>Contact</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">50</context>
+        </context-group>
+      </trans-unit><trans-unit id="f3a58c8a81b9ffda26a154eae25a422c9f7de37b" datatype="html">
+        <source>Organization</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">66</context>
+        </context-group>
+      </trans-unit><trans-unit id="a92e437fac14b4010c4515b31c55a311d026a57f" datatype="html">
+        <source>Proxy</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">94</context>
+        </context-group>
+      </trans-unit><trans-unit id="7d49310535abb3792d8c9c991dd0d990d73d29af" datatype="html">
+        <source>Interval</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">109</context>
+        </context-group>
+      </trans-unit><trans-unit id="7717ce40086b2c8d87ddfb180acf35b5f7dca14b" datatype="html">
+        <source>The entered value must be at least 24 hours.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">121</context>
+        </context-group>
+      </trans-unit><trans-unit id="eae7086660cf1e38c7194a2c49ff52cc656f90f5" datatype="html">
+        <source>The entered value needs to be a number.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">124</context>
+        </context-group>
+      </trans-unit><trans-unit id="047f50bc5b5d17b5bec0196355953e1a5c590ddb" datatype="html">
+        <source>Update</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
+          <context context-type="linenumber">134</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>
+          <context context-type="linenumber">41</context>
+        </context-group>
       </trans-unit><trans-unit id="e3c028c58f92453d46f09b5adf95b2f013ee0300" datatype="html">
         <source>Sorry, we could not find what you were looking for</source>
         <context-group purpose="location">
           <context context-type="sourcefile">app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html</context>
           <context context-type="linenumber">33</context>
         </context-group>
-      </trans-unit><trans-unit id="047f50bc5b5d17b5bec0196355953e1a5c590ddb" datatype="html">
-        <source>Update</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html</context>
-          <context context-type="linenumber">41</context>
-        </context-group>
       </trans-unit><trans-unit id="fa61522d482349707fd7dd03b90dc5781611b17f" datatype="html">
         <source><x id="ICU" equiv-text="{mode, select, edit {...} other {...}}"/>
   pool mirror peer</source>
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="ac6c9212896d39b23811ed2dadab7d07336ec559" datatype="html">
+        <source>Enable</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6766a926d80bb7763785621098dae459d6226429" datatype="html">
+        <source>Disable</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6a0b3657745dd7a2f2162f1cc790bf9004d0845d" datatype="html">
+        <source>Reconnecting, please wait ...</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts</context>
+          <context context-type="linenumber">1</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="1d7fbcc3d5efc946ffbcf86fed04c4e20dda20fb" datatype="html">
         <source>Each object is split in data-chunks parts, each stored on a different OSD.</source>
         <context-group purpose="location">
index 35bed38858d4dc9b00611e2db18a6030282e682d..8022f494994d9312d8962fa1ab68971abcd232ba 100644 (file)
@@ -353,3 +353,7 @@ uib-accordion .panel-title,
   background-size: contain;
   background-repeat: no-repeat;
 }
+/* Block UI */
+.block-ui-wrapper {
+  background: $color-transparent-black !important;
+}
index c66a4a463cd3f4ee25c417d1d753d81cf65eb85c..4b8f1bfa0c20c5fe0d00cc2c04bc10709f47325a 100644 (file)
@@ -879,3 +879,31 @@ def get_request_body_params(request):
         params.update(request.json.items())
 
     return params
+
+
+def find_object_in_list(key, value, iterable):
+    """
+    Get the first occurrence of an object within a list with
+    the specified key/value.
+
+    >>> find_object_in_list('name', 'bar', [{'name': 'foo'}, {'name': 'bar'}])
+    {'name': 'bar'}
+
+    >>> find_object_in_list('name', 'xyz', [{'name': 'foo'}, {'name': 'bar'}]) is None
+    True
+
+    >>> find_object_in_list('foo', 'bar', [{'xyz': 4815162342}]) is None
+    True
+
+    >>> find_object_in_list('foo', 'bar', []) is None
+    True
+
+    :param key: The name of the key.
+    :param value: The value to search for.
+    :param iterable: The list to process.
+    :return: Returns the found object or None.
+    """
+    for obj in iterable:
+        if key in obj and obj[key] == value:
+            return obj
+    return None