From: Volker Theile Date: Thu, 7 Feb 2019 14:54:24 +0000 (+0100) Subject: mgr/dashboard: Add UI to configure the telemetry mgr plugin X-Git-Tag: v14.1.0~125^2 X-Git-Url: http://git.apps.os.sepia.ceph.com/?a=commitdiff_plain;h=b9177e43c08cecdab7fcae2227bf3de83f888c60;p=ceph-ci.git mgr/dashboard: Add UI to configure the telemetry mgr plugin Fixes: tracker.ceph.com/issues/36488 Signed-off-by: Volker Theile --- diff --git a/qa/suites/rados/mgr/tasks/dashboard.yaml b/qa/suites/rados/mgr/tasks/dashboard.yaml index cda888a6aa8..0c2dfc88040 100644 --- a/qa/suites/rados/mgr/tasks/dashboard.yaml +++ b/qa/suites/rados/mgr/tasks/dashboard.yaml @@ -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 diff --git a/qa/tasks/ceph_test_case.py b/qa/tasks/ceph_test_case.py index adcf3fbecb3..41a087abd84 100644 --- a/qa/tasks/ceph_test_case.py +++ b/qa/tasks/ceph_test_case.py @@ -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(): diff --git a/qa/tasks/mgr/dashboard/helper.py b/qa/tasks/mgr/dashboard/helper.py index 56fef683550..a0beee32a40 100644 --- a/qa/tasks/mgr/dashboard/helper.py +++ b/qa/tasks/mgr/dashboard/helper.py @@ -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 index 00000000000..8348476fe41 --- /dev/null +++ b/qa/tasks/mgr/dashboard/test_mgr_module.py @@ -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']) diff --git a/qa/tasks/mgr/dashboard/test_rgw.py b/qa/tasks/mgr/dashboard/test_rgw.py index eb8cff6cb23..b4c0676b0dd 100644 --- a/qa/tasks/mgr/dashboard/test_rgw.py +++ b/qa/tasks/mgr/dashboard/test_rgw.py @@ -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 index 00000000000..26eeebd9fc2 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/mgr_modules.py @@ -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 diff --git a/src/pybind/mgr/dashboard/frontend/package-lock.json b/src/pybind/mgr/dashboard/frontend/package-lock.json index 70e9eaed8f3..295abddb146 100644 --- a/src/pybind/mgr/dashboard/frontend/package-lock.json +++ b/src/pybind/mgr/dashboard/frontend/package-lock.json @@ -8805,6 +8805,14 @@ "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", diff --git a/src/pybind/mgr/dashboard/frontend/package.json b/src/pybind/mgr/dashboard/frontend/package.json index b2fa350ea2c..527642e93c9 100644 --- a/src/pybind/mgr/dashboard/frontend/package.json +++ b/src/pybind/mgr/dashboard/frontend/package.json @@ -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", diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 908e398398c..f8ef7b6b568 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -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', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.component.html b/src/pybind/mgr/dashboard/frontend/src/app/app.component.html index f21c571da63..23b65ac2a8c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/app.component.html @@ -1,6 +1,8 @@ - -
- - -
+ + +
+ + +
+ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts index 058b4788cb6..920069ab0bf 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts @@ -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(), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts index 0190e52e221..5d61b6909a1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts @@ -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 index 00000000000..d0efeaef073 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.html @@ -0,0 +1,15 @@ + + + + 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 index 00000000000..e69de29bb2d 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 index 00000000000..3739b8f541c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.spec.ts @@ -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; + + 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 index 00000000000..94c7cf46021 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts @@ -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 index 00000000000..e101fef6325 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules.module.ts @@ -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 index 00000000000..58e4c076d4a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.html @@ -0,0 +1,144 @@ +Loading configuration... +The configuration could not be loaded. + +
+
+
+
+

Telemetry

+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+ +
+ + This is not a valid email address. +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ + This field is required. + The entered value must be at least 24 hours. + The entered value needs to be a number. +
+
+ +
+ +
+
+
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 index 00000000000..e69de29bb2d 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 index 00000000000..63f7766e3e6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.spec.ts @@ -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; + + 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 index 00000000000..0245d2ddf4b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.ts @@ -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 }); + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html index a77c7d37035..52ac52a110d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html @@ -84,6 +84,13 @@ class="dropdown-item" routerLink="/crush-map">CRUSH map +
  • + Manager Modules +
  • 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 index 00000000000..f28591508a9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts @@ -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 index 00000000000..f5d68ca071c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts @@ -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} + */ + list() { + return this.http.get(`${this.url}`); + } + + /** + * Get the Ceph Mgr module configuration. + * @param {string} module The name of the mgr module. + * @return {Observable} + */ + 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} + */ + 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); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts index 18738f69350..0612d2f7f28 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts @@ -15,6 +15,8 @@ import { TaskMessageService } from './task-message.service'; providedIn: ServicesModule }) export class NotificationService { + private hideToasties = false; + // Observable sources private dataSource = new BehaviorSubject([]); 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 + '
    ' : '') + 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; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf b/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf index e515f5f35c7..88af04bd3bb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf +++ b/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf @@ -62,23 +62,29 @@ app/core/navigation/navigation/navigation.component.html 85 + + Manager Modules + + app/core/navigation/navigation/navigation.component.html + 92 + Logs app/core/navigation/navigation/navigation.component.html - 92 + 99 Alerts app/core/navigation/navigation/navigation.component.html - 98 + 105 Pools app/core/navigation/navigation/navigation.component.html - 108 + 115 app/ceph/block/mirroring/overview/overview.component.html @@ -96,13 +102,13 @@ Block app/core/navigation/navigation/navigation.component.html - 122 + 129 Images app/core/navigation/navigation/navigation.component.html - 131 + 138 app/ceph/block/iscsi-target-form/iscsi-target-form.component.html @@ -132,31 +138,31 @@ Mirroring app/core/navigation/navigation/navigation.component.html - 139 + 146 iSCSI app/core/navigation/navigation/navigation.component.html - 151 + 158 Filesystems app/core/navigation/navigation/navigation.component.html - 162 + 169 Object Gateway app/core/navigation/navigation/navigation.component.html - 173 + 180 Daemons app/core/navigation/navigation/navigation.component.html - 182 + 189 app/ceph/block/iscsi/iscsi.component.html @@ -170,7 +176,7 @@ Users app/core/navigation/navigation/navigation.component.html - 188 + 195 app/core/auth/user-tabs/user-tabs.component.html @@ -180,7 +186,7 @@ Buckets app/core/navigation/navigation/navigation.component.html - 194 + 201 Retrieving data for @@ -242,6 +248,10 @@ app/core/auth/user-form/user-form.component.html 151 + + app/core/mgr-modules/telemetry/telemetry.component.html + 139 + Select a Language @@ -573,6 +583,10 @@ app/core/auth/user-form/user-form.component.html 87 + + app/core/mgr-modules/telemetry/telemetry.component.html + 118 + app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.html 36 @@ -1184,6 +1198,10 @@ app/core/auth/role-form/role-form.component.html 46 + + app/core/mgr-modules/telemetry/telemetry.component.html + 80 + app/ceph/cluster/configuration/configuration-details/configuration-details.component.html 13 @@ -2328,6 +2346,10 @@ app/ceph/rgw/rgw-user-form/rgw-user-form.component.html 79 + + app/core/mgr-modules/telemetry/telemetry.component.html + 58 + The chosen email address is already in use. @@ -2494,6 +2516,10 @@ app/ceph/rgw/rgw-user-form/rgw-user-form.component.html 506 + + app/core/mgr-modules/telemetry/telemetry.component.html + 27 + app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html 71 @@ -2717,6 +2743,76 @@ app/core/forbidden/forbidden.component.html 7 + + Loading configuration... + + app/core/mgr-modules/telemetry/telemetry.component.html + 2 + + + The configuration could not be loaded. + + app/core/mgr-modules/telemetry/telemetry.component.html + 4 + + + Telemetry + + app/core/mgr-modules/telemetry/telemetry.component.html + 15 + + + Leaderboard + + app/core/mgr-modules/telemetry/telemetry.component.html + 40 + + + Contact + + app/core/mgr-modules/telemetry/telemetry.component.html + 50 + + + Organization + + app/core/mgr-modules/telemetry/telemetry.component.html + 66 + + + Proxy + + app/core/mgr-modules/telemetry/telemetry.component.html + 94 + + + Interval + + app/core/mgr-modules/telemetry/telemetry.component.html + 109 + + + The entered value must be at least 24 hours. + + app/core/mgr-modules/telemetry/telemetry.component.html + 121 + + + The entered value needs to be a number. + + app/core/mgr-modules/telemetry/telemetry.component.html + 124 + + + Update + + app/core/mgr-modules/telemetry/telemetry.component.html + 134 + + + app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html + 41 + Sorry, we could not find what you were looking for @@ -3507,12 +3603,6 @@ app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html 33 - - Update - - app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html - 41 - pool mirror peer @@ -5093,6 +5183,27 @@ 1 + + Enable + + src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts + 1 + + + + Disable + + src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts + 1 + + + + Reconnecting, please wait ... + + src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts + 1 + + Each object is split in data-chunks parts, each stored on a different OSD. diff --git a/src/pybind/mgr/dashboard/frontend/src/styles.scss b/src/pybind/mgr/dashboard/frontend/src/styles.scss index 35bed38858d..8022f494994 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles.scss @@ -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; +} diff --git a/src/pybind/mgr/dashboard/tools.py b/src/pybind/mgr/dashboard/tools.py index c66a4a463cd..4b8f1bfa0c2 100644 --- a/src/pybind/mgr/dashboard/tools.py +++ b/src/pybind/mgr/dashboard/tools.py @@ -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