- tasks.mgr.dashboard.test_settings
- tasks.mgr.dashboard.test_user
- tasks.mgr.dashboard.test_erasure_code_profile
+ - tasks.mgr.dashboard.test_mgr_module
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():
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):
--- /dev/null
+# -*- 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'])
from .helper import DashboardTestCase, JObj, JList, JLeaf
-
logger = logging.getLogger(__name__)
# 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
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):
@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):
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),
# 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)
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)
# 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)
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)
# 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)
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')
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):
})
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):
})
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')
# 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):
# 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)
--- /dev/null
+# -*- 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
"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",
"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",
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';
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',
-<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>
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';
declarations: [AppComponent],
imports: [
HttpClientModule,
+ BlockUIModule.forRoot(),
BrowserModule,
BrowserAnimationsModule,
ToastModule.forRoot(),
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]
})
--- /dev/null
+<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>
--- /dev/null
+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([]);
+ });
+ });
+ });
+});
--- /dev/null
+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();
+ }
+ );
+ }
+}
--- /dev/null
+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 {}
--- /dev/null
+<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>
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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 });
+ }
+ );
+ }
+}
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">
--- /dev/null
+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');
+ });
+});
--- /dev/null
+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);
+ }
+}
providedIn: ServicesModule
})
export class NotificationService {
+ private hideToasties = false;
+
// Observable sources
private dataSource = new BehaviorSubject<CdNotification[]>([]);
private queuedNotifications: CdNotificationConfig[] = [];
}
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),
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;
+ }
}
<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="<span>"/> 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">
background-size: contain;
background-repeat: no-repeat;
}
+/* Block UI */
+.block-ui-wrapper {
+ background: $color-transparent-black !important;
+}
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