From: Patrick Nawracay Date: Mon, 29 Oct 2018 08:59:59 +0000 (+0100) Subject: mgr/dashboard: Add support for managing RBD QoS X-Git-Tag: v14.1.0~63^2~1 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=cfbefbf169635912c6608488182d924f0455ecc5;p=ceph.git mgr/dashboard: Add support for managing RBD QoS Fixes: http://tracker.ceph.com/issues/36191 Signed-off-by: Patrick Nawracay --- diff --git a/qa/suites/rados/mgr/tasks/dashboard.yaml b/qa/suites/rados/mgr/tasks/dashboard.yaml index 0c2dfc880404..d24cdf63a6a2 100644 --- a/qa/suites/rados/mgr/tasks/dashboard.yaml +++ b/qa/suites/rados/mgr/tasks/dashboard.yaml @@ -20,6 +20,7 @@ tasks: - \(MDS_UP_LESS_THAN_MAX\) - \(OSD_DOWN\) - \(OSD_HOST_DOWN\) + - \(POOL_APP_NOT_ENABLED\) - pauserd,pausewr flag\(s\) set - Monitor daemon marked osd\.[[:digit:]]+ down, but it is still running - rgw: [client.0] diff --git a/qa/tasks/mgr/dashboard/test_rbd.py b/qa/tasks/mgr/dashboard/test_rbd.py index 7d8773cb031e..f9b7dd022fa6 100644 --- a/qa/tasks/mgr/dashboard/test_rbd.py +++ b/qa/tasks/mgr/dashboard/test_rbd.py @@ -89,9 +89,9 @@ class RbdTest(DashboardTestCase): # pylint: disable=too-many-arguments @classmethod - def edit_image(cls, pool, image, name=None, size=None, features=None): - return cls._task_put('/api/block/image/{}/{}'.format(pool, image), - {'name': name, 'size': size, 'features': features}) + def edit_image(cls, pool, image, name=None, size=None, features=None, **kwargs): + kwargs.update({'name': name, 'size': size, 'features': features}) + return cls._task_put('/api/block/image/{}/{}'.format(pool, image), kwargs) @classmethod def flatten_image(cls, pool, image): @@ -139,7 +139,7 @@ class RbdTest(DashboardTestCase): '--yes-i-really-really-mean-it']) @classmethod - def create_image_in_trash(cls, pool, name, delay=0, **kwargs): + def create_image_in_trash(cls, pool, name, delay=0): cls.create_image(pool, name, 10240) img = cls._get('/api/block/image/{}/{}'.format(pool, name)) @@ -201,6 +201,11 @@ class RbdTest(DashboardTestCase): 'timestamp': JLeaf(str, none=True), 'disk_usage': JLeaf(int, none=True), 'total_disk_usage': JLeaf(int, none=True), + 'configuration': JList(JObj(sub_elems={ + 'name': JLeaf(str), + 'source': JLeaf(int), + 'value': JLeaf(str), + })), }) self.assertSchema(img, schema) @@ -288,6 +293,33 @@ class RbdTest(DashboardTestCase): self.remove_image('rbd', rbd_name) + def test_create_with_configuration(self): + pool = 'rbd' + image_name = 'image_with_config' + size = 10240 + configuration = { + 'rbd_qos_bps_limit': 10240, + 'rbd_qos_bps_burst': 10240 * 2, + } + expected = [{ + 'name': 'rbd_qos_bps_limit', + 'source': 2, + 'value': str(10240), + }, { + 'name': 'rbd_qos_bps_burst', + 'source': 2, + 'value': str(10240 * 2), + }] + + self.create_image(pool, image_name, size, configuration=configuration) + self.assertStatus(201) + img = self._get('/api/block/image/rbd/{}'.format(image_name)) + self.assertStatus(200) + for conf in expected: + self.assertIn(conf, img['configuration']) + + self.remove_image(pool, image_name) + def test_create_rbd_in_data_pool(self): if not self.bluestore_support: self.skipTest('requires bluestore cluster') @@ -460,6 +492,57 @@ class RbdTest(DashboardTestCase): self.remove_image('rbd', 'edit_img') self.assertStatus(204) + def test_image_change_config(self): + pool = 'rbd' + image = 'image_with_config' + initial_conf = { + 'rbd_qos_bps_limit': 10240, + 'rbd_qos_write_iops_limit': None + } + initial_expect = [{ + 'name': 'rbd_qos_bps_limit', + 'source': 2, + 'value': '10240', + }, { + 'name': 'rbd_qos_write_iops_limit', + 'source': 0, + 'value': '0', + }] + new_conf = { + 'rbd_qos_bps_limit': 0, + 'rbd_qos_bps_burst': 20480, + 'rbd_qos_write_iops_limit': None + } + new_expect = [{ + 'name': 'rbd_qos_bps_limit', + 'source': 2, + 'value': '0', + }, { + 'name': 'rbd_qos_bps_burst', + 'source': 2, + 'value': '20480', + }, { + 'name': 'rbd_qos_write_iops_limit', + 'source': 0, + 'value': '0', + }] + + self.create_image(pool, image, 2**30, configuration=initial_conf) + self.assertStatus(201) + img = self._get('/api/block/image/{}/{}'.format(pool, image)) + self.assertStatus(200) + for conf in initial_expect: + self.assertIn(conf, img['configuration']) + + self.edit_image(pool, image, configuration=new_conf) + img = self._get('/api/block/image/{}/{}'.format(pool, image)) + self.assertStatus(200) + for conf in new_expect: + self.assertIn(conf, img['configuration']) + + self.remove_image(pool, image) + self.assertStatus(204) + def test_update_snapshot(self): self.create_snapshot('rbd', 'img1', 'snap5') self.assertStatus(201) diff --git a/src/pybind/mgr/dashboard/controllers/__init__.py b/src/pybind/mgr/dashboard/controllers/__init__.py index e8839a43b3cb..339646638681 100644 --- a/src/pybind/mgr/dashboard/controllers/__init__.py +++ b/src/pybind/mgr/dashboard/controllers/__init__.py @@ -317,24 +317,24 @@ class Task(object): @wraps(func) def wrapper(*args, **kwargs): arg_map = self._gen_arg_map(func, args, kwargs) - md = {} + metadata = {} for k, v in self.metadata.items(): if isinstance(v, str) and v and v[0] == '{' and v[-1] == '}': param = v[1:-1] try: pos = int(param) - md[k] = arg_map[pos] + metadata[k] = arg_map[pos] except ValueError: if param.find('.') == -1: - md[k] = arg_map[param] + metadata[k] = arg_map[param] else: path = param.split('.') - md[k] = arg_map[path[0]] + metadata[k] = arg_map[path[0]] for i in range(1, len(path)): - md[k] = md[k][path[i]] + metadata[k] = metadata[k][path[i]] else: - md[k] = v - task = TaskManager.run(self.name, md, func, args, kwargs, + metadata[k] = v + task = TaskManager.run(self.name, metadata, func, args, kwargs, exception_handler=self.exception_handler) try: status, value = task.wait(self.wait_for) @@ -350,7 +350,7 @@ class Task(object): raise ex if status == TaskManager.VALUE_EXECUTING: cherrypy.response.status = 202 - return {'name': self.name, 'metadata': md} + return {'name': self.name, 'metadata': metadata} return value return wrapper diff --git a/src/pybind/mgr/dashboard/controllers/pool.py b/src/pybind/mgr/dashboard/controllers/pool.py index cdd9a7110d45..b20e19842f17 100644 --- a/src/pybind/mgr/dashboard/controllers/pool.py +++ b/src/pybind/mgr/dashboard/controllers/pool.py @@ -8,6 +8,7 @@ from . import ApiController, RESTController, Endpoint, ReadPermission, Task from .. import mgr from ..security import Scope from ..services.ceph_service import CephService +from ..services.rbd import RbdConfiguration from ..services.exception import handle_send_command_error from ..tools import str_to_bool @@ -67,7 +68,9 @@ class Pool(RESTController): def get(self, pool_name, attrs=None, stats=False): # type: (str, str, bool) -> dict - return self._get(pool_name, attrs, stats) + pool = self._get(pool_name, attrs, stats) + pool['configuration'] = RbdConfiguration(pool_name).list() + return pool @pool_task('delete', ['{pool_name}']) @handle_send_command_error('pool') @@ -76,19 +79,20 @@ class Pool(RESTController): yes_i_really_really_mean_it=True) @pool_task('edit', ['{pool_name}']) - def set(self, pool_name, flags=None, application_metadata=None, **kwargs): + def set(self, pool_name, flags=None, application_metadata=None, configuration=None, **kwargs): self._set_pool_values(pool_name, application_metadata, flags, True, kwargs) + RbdConfiguration(pool_name).set_configuration(configuration) @pool_task('create', {'pool_name': '{pool}'}) @handle_send_command_error('pool') def create(self, pool, pg_num, pool_type, erasure_code_profile=None, flags=None, - application_metadata=None, rule_name=None, **kwargs): + application_metadata=None, rule_name=None, configuration=None, **kwargs): ecp = erasure_code_profile if erasure_code_profile else None CephService.send_command('mon', 'osd pool create', pool=pool, pg_num=int(pg_num), pgp_num=int(pg_num), pool_type=pool_type, erasure_code_profile=ecp, rule=rule_name) - self._set_pool_values(pool, application_metadata, flags, False, kwargs) + RbdConfiguration(pool).set_configuration(configuration) def _set_pool_values(self, pool, application_metadata, flags, update_existing, kwargs): update_name = False @@ -137,10 +141,17 @@ class Pool(RESTController): reset_arg(arg, '0') reset_arg('compression_algorithm', 'unset') + @RESTController.Resource() + @ReadPermission + def configuration(self, pool_name): + return RbdConfiguration(pool_name).list() + @Endpoint() @ReadPermission - def _info(self): + def _info(self, pool_name=''): + # type: (str) -> dict """Used by the create-pool dialog""" + def rules(pool_type): return [r for r in mgr.get('osd_map_crush')['rules'] @@ -155,7 +166,7 @@ class Pool(RESTController): for o in mgr.get('config_options')['options'] if o['name'] == conf_name][0] - return { + result = { "pool_names": [p['pool_name'] for p in self._pool_list()], "crush_rules_replicated": rules(1), "crush_rules_erasure": rules(3), @@ -165,3 +176,8 @@ class Pool(RESTController): "compression_algorithms": compression_enum('bluestore_compression_algorithm'), "compression_modes": compression_enum('bluestore_compression_mode'), } + + if pool_name: + result['pool_options'] = RbdConfiguration(pool_name).list() + + return result diff --git a/src/pybind/mgr/dashboard/controllers/rbd.py b/src/pybind/mgr/dashboard/controllers/rbd.py index f16f7b56c5c8..14a751b93c2c 100644 --- a/src/pybind/mgr/dashboard/controllers/rbd.py +++ b/src/pybind/mgr/dashboard/controllers/rbd.py @@ -13,10 +13,11 @@ import six import rbd from . import ApiController, RESTController, Task, UpdatePermission, \ - DeletePermission, CreatePermission + DeletePermission, CreatePermission, ReadPermission from .. import mgr from ..security import Scope from ..services.ceph_service import CephService +from ..services.rbd import RbdConfiguration from ..tools import ViewCache, str_to_bool from ..services.exception import handle_rados_error, handle_rbd_error, \ serialize_dashboard_exception @@ -121,12 +122,11 @@ class Rbd(RESTController): RESOURCE_ID = "pool_name/image_name" # set of image features that can be enable on existing images - ALLOW_ENABLE_FEATURES = set(["exclusive-lock", "object-map", "fast-diff", - "journaling"]) + ALLOW_ENABLE_FEATURES = {"exclusive-lock", "object-map", "fast-diff", "journaling"} # set of image features that can be disabled on existing images - ALLOW_DISABLE_FEATURES = set(["exclusive-lock", "object-map", "fast-diff", - "deep-flatten", "journaling"]) + ALLOW_DISABLE_FEATURES = {"exclusive-lock", "object-map", "fast-diff", "deep-flatten", + "journaling"} @classmethod def _rbd_disk_usage(cls, image, snaps, whole_object=True): @@ -152,7 +152,8 @@ class Rbd(RESTController): return total_used_size, snap_map - def _rbd_image(self, ioctx, pool_name, image_name): + @classmethod + def _rbd_image(cls, ioctx, pool_name, image_name): with rbd.Image(ioctx, image_name) as img: stat = img.stat() stat['name'] = image_name @@ -213,7 +214,7 @@ class Rbd(RESTController): for s in stat['snapshots']] snaps.sort(key=lambda s: s[0]) snaps += [(snaps[-1][0]+1 if snaps else 0, stat['size'], None)] - total_prov_bytes, snaps_prov_bytes = self._rbd_disk_usage( + total_prov_bytes, snaps_prov_bytes = cls._rbd_disk_usage( img, snaps, True) stat['total_disk_usage'] = total_prov_bytes for snap, prov_bytes in snaps_prov_bytes.items(): @@ -228,17 +229,20 @@ class Rbd(RESTController): stat['total_disk_usage'] = None stat['disk_usage'] = None + stat['configuration'] = RbdConfiguration(pool_ioctx=ioctx, image_name=image_name).list() + return stat + @classmethod @ViewCache() - def _rbd_pool_list(self, pool_name): + def _rbd_pool_list(cls, pool_name): rbd_inst = rbd.RBD() with mgr.rados.open_ioctx(pool_name) as ioctx: names = rbd_inst.list(ioctx) result = [] for name in names: try: - stat = self._rbd_image(ioctx, pool_name, name) + stat = cls._rbd_image(ioctx, pool_name, name) except rbd.ImageNotFound: # may have been removed in the meanwhile continue @@ -255,6 +259,8 @@ class Rbd(RESTController): for pool in pools: # pylint: disable=unbalanced-tuple-unpacking status, value = self._rbd_pool_list(pool) + for i, image in enumerate(value): + value[i]['configuration'] = RbdConfiguration(pool, image['name']).list() result.append({'status': status, 'value': value, 'pool_name': pool}) return result @@ -275,7 +281,7 @@ class Rbd(RESTController): @RbdTask('create', {'pool_name': '{pool_name}', 'image_name': '{name}'}, 2.0) def create(self, name, pool_name, size, obj_size=None, features=None, - stripe_unit=None, stripe_count=None, data_pool=None): + stripe_unit=None, stripe_count=None, data_pool=None, configuration=None): size = int(size) @@ -293,8 +299,9 @@ class Rbd(RESTController): rbd_inst.create(ioctx, name, size, order=l_order, old_format=False, features=feature_bitmask, stripe_unit=stripe_unit, stripe_count=stripe_count, data_pool=data_pool) + RbdConfiguration(pool_ioctx=ioctx, image_name=name).set_configuration(configuration) - return _rbd_call(pool_name, _create) + _rbd_call(pool_name, _create) @RbdTask('delete', ['{pool_name}', '{image_name}'], 2.0) def delete(self, pool_name, image_name): @@ -302,7 +309,7 @@ class Rbd(RESTController): return _rbd_call(pool_name, rbd_inst.remove, image_name) @RbdTask('edit', ['{pool_name}', '{image_name}', '{name}'], 4.0) - def set(self, pool_name, image_name, name=None, size=None, features=None): + def set(self, pool_name, image_name, name=None, size=None, features=None, configuration=None): def _edit(ioctx, image): rbd_inst = rbd.RBD() # check rename image @@ -329,6 +336,9 @@ class Rbd(RESTController): f_bitmask = _format_features([feature]) image.update_features(f_bitmask, True) + RbdConfiguration(pool_ioctx=ioctx, image_name=image_name).set_configuration( + configuration) + return _rbd_image_call(pool_name, image_name, _edit) @RbdTask('copy', @@ -339,7 +349,7 @@ class Rbd(RESTController): @RESTController.Resource('POST') def copy(self, pool_name, image_name, dest_pool_name, dest_image_name, snapshot_name=None, obj_size=None, features=None, stripe_unit=None, - stripe_count=None, data_pool=None): + stripe_count=None, data_pool=None, configuration=None): def _src_copy(s_ioctx, s_img): def _copy(d_ioctx): @@ -356,6 +366,8 @@ class Rbd(RESTController): s_img.copy(d_ioctx, dest_image_name, feature_bitmask, l_order, stripe_unit, stripe_count, data_pool) + RbdConfiguration(pool_ioctx=d_ioctx, image_name=dest_image_name).set_configuration( + configuration) return _rbd_call(dest_pool_name, _copy) @@ -386,6 +398,11 @@ class Rbd(RESTController): rbd_inst = rbd.RBD() return _rbd_call(pool_name, rbd_inst.trash_move, image_name, delay) + @RESTController.Resource() + @ReadPermission + def configuration(self, pool_name, image_name): + return RbdConfiguration(pool_name, image_name).list() + @ApiController('/block/image/{pool_name}/{image_name}/snap', Scope.RBD_IMAGE) class RbdSnapshot(RESTController): @@ -444,8 +461,11 @@ class RbdSnapshot(RESTController): 'child_image_name': '{child_image_name}'}, 2.0) @RESTController.Resource('POST') def clone(self, pool_name, image_name, snapshot_name, child_pool_name, - child_image_name, obj_size=None, features=None, - stripe_unit=None, stripe_count=None, data_pool=None): + child_image_name, obj_size=None, features=None, stripe_unit=None, stripe_count=None, + data_pool=None, configuration=None): + """ + Clones a snapshot to an image + """ def _parent_clone(p_ioctx): def _clone(ioctx): @@ -462,9 +482,12 @@ class RbdSnapshot(RESTController): child_image_name, feature_bitmask, l_order, stripe_unit, stripe_count, data_pool) + RbdConfiguration(pool_ioctx=ioctx, image_name=child_image_name).set_configuration( + configuration) + return _rbd_call(child_pool_name, _clone) - return _rbd_call(pool_name, _parent_clone) + _rbd_call(pool_name, _parent_clone) @ApiController('/block/image/trash', Scope.RBD_IMAGE) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index b21930172a7f..4c706d4ae629 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -21,6 +21,8 @@ import { IscsiTargetIqnSettingsModalComponent } from './iscsi-target-iqn-setting import { IscsiTargetListComponent } from './iscsi-target-list/iscsi-target-list.component'; import { IscsiComponent } from './iscsi/iscsi.component'; import { MirroringModule } from './mirroring/mirroring.module'; +import { RbdConfigurationFormComponent } from './rbd-configuration-form/rbd-configuration-form.component'; +import { RbdConfigurationListComponent } from './rbd-configuration-list/rbd-configuration-list.component'; import { RbdDetailsComponent } from './rbd-details/rbd-details.component'; import { RbdFormComponent } from './rbd-form/rbd-form.component'; import { RbdImagesComponent } from './rbd-images/rbd-images.component'; @@ -77,7 +79,10 @@ import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-tra IscsiTargetFormComponent, IscsiTargetImageSettingsModalComponent, IscsiTargetIqnSettingsModalComponent, - IscsiTargetDiscoveryModalComponent - ] + IscsiTargetDiscoveryModalComponent, + RbdConfigurationListComponent, + RbdConfigurationFormComponent + ], + exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] }) export class BlockModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html new file mode 100644 index 000000000000..63a5c88fc7c8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.html @@ -0,0 +1,79 @@ +
+ RBD Configuration + +
+ +
+
+ + +
+
+ + + + + + + + + + + + + + +
+ The mininum value is 0 +
+
+
+
+ +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.scss new file mode 100644 index 000000000000..ba6460c32f54 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.scss @@ -0,0 +1,4 @@ +.collapsible { + cursor: pointer; + user-select: none; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.spec.ts new file mode 100644 index 000000000000..dbce86384d25 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.spec.ts @@ -0,0 +1,305 @@ +import { EventEmitter } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; + +import { ComponentLoaderFactory } from 'ngx-bootstrap/component-loader'; +import { PositioningService } from 'ngx-bootstrap/positioning'; +import { TooltipConfig, TooltipModule } from 'ngx-bootstrap/tooltip'; + +import { configureTestBed, FormHelper, i18nProviders } from '../../../../testing/unit-test-helper'; +import { DirectivesModule } from '../../../shared/directives/directives.module'; +import { CdFormGroup } from '../../../shared/forms/cd-form-group'; +import { RbdConfigurationSourceField } from '../../../shared/models/configuration'; +import { DimlessBinaryPerSecondPipe } from '../../../shared/pipes/dimless-binary-per-second.pipe'; +import { FormatterService } from '../../../shared/services/formatter.service'; +import { RbdConfigurationService } from '../../../shared/services/rbd-configuration.service'; +import { SharedModule } from '../../../shared/shared.module'; +import { RbdConfigurationFormComponent } from './rbd-configuration-form.component'; + +describe('RbdConfigurationFormComponent', () => { + let component: RbdConfigurationFormComponent; + let fixture: ComponentFixture; + let sections: any[]; + let fh: FormHelper; + + configureTestBed({ + imports: [ReactiveFormsModule, TooltipModule, DirectivesModule, SharedModule], + declarations: [RbdConfigurationFormComponent], + providers: [ + ComponentLoaderFactory, + PositioningService, + TooltipConfig, + RbdConfigurationService, + FormatterService, + DimlessBinaryPerSecondPipe, + i18nProviders + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RbdConfigurationFormComponent); + component = fixture.componentInstance; + component.form = new CdFormGroup({}, null); + fh = new FormHelper(component.form); + fixture.detectChanges(); + sections = TestBed.get(RbdConfigurationService).sections; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create all form fields mentioned in RbdConfiguration::OPTIONS', () => { + /* Test form creation on a TypeScript level */ + const actual = Object.keys((component.form.get('configuration') as CdFormGroup).controls); + const expected = sections + .map((section) => section.options) + .reduce((a, b) => a.concat(b)) + .map((option) => option.name); + expect(actual).toEqual(expected); + + /* Test form creation on a template level */ + const controlDebugElements = fixture.debugElement.queryAll(By.css('input.form-control')); + expect(controlDebugElements.length).toBe(expected.length); + controlDebugElements.forEach((element) => expect(element.nativeElement).toBeTruthy()); + }); + + it('should only contain values of changed controls if submitted', () => { + let values = {}; + component.changes.subscribe((getDirtyValues: Function) => { + values = getDirtyValues(); + }); + fh.setValue('configuration.rbd_qos_bps_limit', 0, true); + fixture.detectChanges(); + + expect(values).toEqual({ rbd_qos_bps_limit: 0 }); + }); + + describe('test loading of initial data for editing', () => { + beforeEach(() => { + component.initializeData = new EventEmitter(); + fixture.detectChanges(); + component.ngOnInit(); + }); + + it('should return dirty values without any units', () => { + let dirtyValues = {}; + component.changes.subscribe((getDirtyValues) => { + dirtyValues = getDirtyValues(); + }); + + fh.setValue('configuration.rbd_qos_bps_limit', 55, true); + fh.setValue('configuration.rbd_qos_iops_limit', 22, true); + + expect(dirtyValues['rbd_qos_bps_limit']).toBe(55); + expect(dirtyValues['rbd_qos_iops_limit']).toBe(22); + }); + + it('should load initial data into forms', () => { + component.initializeData.emit({ + initialData: [ + { + name: 'rbd_qos_bps_limit', + value: 55, + source: 1 + } + ], + sourceType: RbdConfigurationSourceField.pool + }); + + expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('55 B/s'); + }); + + it('should not load initial data if the source is not the pool itself', () => { + component.initializeData.emit({ + initialData: [ + { + name: 'rbd_qos_bps_limit', + value: 55, + source: RbdConfigurationSourceField.image + }, + { + name: 'rbd_qos_iops_limit', + value: 22, + source: RbdConfigurationSourceField.global + } + ], + sourceType: RbdConfigurationSourceField.pool + }); + + expect(component.form.getValue('configuration.rbd_qos_iops_limit')).toEqual('0 IOPS'); + expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('0 B/s'); + }); + + it('should not load initial data if the source is not the image itself', () => { + component.initializeData.emit({ + initialData: [ + { + name: 'rbd_qos_bps_limit', + value: 55, + source: RbdConfigurationSourceField.pool + }, + { + name: 'rbd_qos_iops_limit', + value: 22, + source: RbdConfigurationSourceField.global + } + ], + sourceType: RbdConfigurationSourceField.image + }); + + expect(component.form.getValue('configuration.rbd_qos_iops_limit')).toEqual('0 IOPS'); + expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('0 B/s'); + }); + + it('should always have formatted results', () => { + component.initializeData.emit({ + initialData: [ + { + name: 'rbd_qos_bps_limit', + value: 55, + source: RbdConfigurationSourceField.image + }, + { + name: 'rbd_qos_iops_limit', + value: 22, + source: RbdConfigurationSourceField.image + }, + { + name: 'rbd_qos_read_bps_limit', + value: null, // incorrect type + source: RbdConfigurationSourceField.image + }, + { + name: 'rbd_qos_read_bps_limit', + value: undefined, // incorrect type + source: RbdConfigurationSourceField.image + } + ], + sourceType: RbdConfigurationSourceField.image + }); + + expect(component.form.getValue('configuration.rbd_qos_iops_limit')).toEqual('22 IOPS'); + expect(component.form.getValue('configuration.rbd_qos_bps_limit')).toEqual('55 B/s'); + expect(component.form.getValue('configuration.rbd_qos_read_bps_limit')).toEqual('0 B/s'); + expect(component.form.getValue('configuration.rbd_qos_read_bps_limit')).toEqual('0 B/s'); + }); + }); + + it('should reset the corresponding form field correctly', () => { + const fieldName = 'rbd_qos_bps_limit'; + const getValue = () => component.form.get(`configuration.${fieldName}`).value; + + // Initialization + fh.setValue(`configuration.${fieldName}`, 418, true); + expect(getValue()).toBe(418); + + // Reset + component.reset(fieldName); + expect(getValue()).toBe(null); + + // Restore + component.reset(fieldName); + expect(getValue()).toBe(418); + + // Reset + component.reset(fieldName); + expect(getValue()).toBe(null); + + // Restore + component.reset(fieldName); + expect(getValue()).toBe(418); + }); + + describe('should verify that getDirtyValues() returns correctly', () => { + let data; + + beforeEach(() => { + component.initializeData = new EventEmitter(); + fixture.detectChanges(); + component.ngOnInit(); + data = { + initialData: [ + { + name: 'rbd_qos_bps_limit', + value: 0, + source: RbdConfigurationSourceField.image + }, + { + name: 'rbd_qos_iops_limit', + value: 0, + source: RbdConfigurationSourceField.image + }, + { + name: 'rbd_qos_read_bps_limit', + value: 0, + source: RbdConfigurationSourceField.image + }, + { + name: 'rbd_qos_read_iops_limit', + value: 0, + source: RbdConfigurationSourceField.image + }, + { + name: 'rbd_qos_read_iops_burst', + value: 0, + source: RbdConfigurationSourceField.image + }, + { + name: 'rbd_qos_write_bps_burst', + value: undefined, + source: RbdConfigurationSourceField.global + }, + { + name: 'rbd_qos_write_iops_burst', + value: null, + source: RbdConfigurationSourceField.global + } + ], + sourceType: RbdConfigurationSourceField.image + }; + component.initializeData.emit(data); + }); + + it('should return an empty object', () => { + expect(component.getDirtyValues()).toEqual({}); + expect(component.getDirtyValues(true, RbdConfigurationSourceField.image)).toEqual({}); + }); + + it('should return dirty values', () => { + component.form.get('configuration.rbd_qos_write_bps_burst').markAsDirty(); + expect(component.getDirtyValues()).toEqual({ rbd_qos_write_bps_burst: 0 }); + + component.form.get('configuration.rbd_qos_write_iops_burst').markAsDirty(); + expect(component.getDirtyValues()).toEqual({ + rbd_qos_write_iops_burst: 0, + rbd_qos_write_bps_burst: 0 + }); + }); + + it('should also return all local values if they do not contain their initial values', () => { + // Change value for all options + data.initialData = data.initialData.map((o) => { + o.value = 22; + return o; + }); + + // Mark some dirty + ['rbd_qos_read_iops_limit', 'rbd_qos_write_bps_burst'].forEach((option) => { + component.form.get(`configuration.${option}`).markAsDirty(); + }); + + expect(component.getDirtyValues(true, RbdConfigurationSourceField.image)).toEqual({ + rbd_qos_read_iops_limit: 0, + rbd_qos_write_bps_burst: 0 + }); + }); + + it('should throw an error if used incorrectly', () => { + expect(() => component.getDirtyValues(true)).toThrowError( + /^ProgrammingError: If local values shall be included/ + ); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.ts new file mode 100644 index 000000000000..072ab6fea35e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-configuration-form.component.ts @@ -0,0 +1,152 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; + +import { CdFormGroup } from '../../../shared/forms/cd-form-group'; +import { + RbdConfigurationEntry, + RbdConfigurationSourceField, + RbdConfigurationType +} from '../../../shared/models/configuration'; +import { FormatterService } from '../../../shared/services/formatter.service'; +import { RbdConfigurationService } from '../../../shared/services/rbd-configuration.service'; + +@Component({ + selector: 'cd-rbd-configuration-form', + templateUrl: './rbd-configuration-form.component.html', + styleUrls: ['./rbd-configuration-form.component.scss'] +}) +export class RbdConfigurationFormComponent implements OnInit { + @Input() + form: CdFormGroup; + @Input() + initializeData: EventEmitter<{ + initialData: RbdConfigurationEntry[]; + sourceType: RbdConfigurationSourceField; + }>; + @Output() + changes = new EventEmitter(); + ngDataReady = new EventEmitter(); + initialData: RbdConfigurationEntry[]; + configurationType = RbdConfigurationType; + sectionVisibility: { [key: string]: boolean } = {}; + + constructor( + public formatterService: FormatterService, + public rbdConfigurationService: RbdConfigurationService + ) {} + + ngOnInit() { + const configFormGroup = this.createConfigurationFormGroup(); + this.form.addControl('configuration', configFormGroup); + + // Listen to changes and emit the values to the parent component + configFormGroup.valueChanges.subscribe(() => { + this.changes.emit(this.getDirtyValues.bind(this)); + }); + + if (this.initializeData) { + this.initializeData.subscribe((data) => { + this.initialData = data.initialData; + const dataType = data.sourceType; + + this.rbdConfigurationService.getWritableOptionFields().forEach((option) => { + const optionData = data.initialData.filter((entry) => entry.name === option.name).pop(); + if (optionData && optionData['source'] === dataType) { + this.form.get(`configuration.${option.name}`).setValue(optionData['value']); + } + }); + this.ngDataReady.emit(); + }); + } + + this.rbdConfigurationService + .getWritableSections() + .forEach((section) => (this.sectionVisibility[section.class] = false)); + } + + getDirtyValues(includeLocalValues = false, localFieldType?: RbdConfigurationSourceField) { + if (includeLocalValues && !localFieldType) { + const msg = + 'ProgrammingError: If local values shall be included, a proper localFieldType argument has to be provided, too'; + throw new Error(msg); + } + const result = {}; + + this.rbdConfigurationService.getWritableOptionFields().forEach((config) => { + const control = this.form.get('configuration').get(config.name); + const dirty = control.dirty; + + if (this.initialData && this.initialData[config.name] === control.value) { + return; // Skip controls with initial data loaded + } + + if (dirty || (includeLocalValues && control['source'] === localFieldType)) { + if (control.value === null) { + result[config.name] = control.value; + } else if (config.type === RbdConfigurationType.bps) { + result[config.name] = this.formatterService.toBytes(control.value); + } else if (config.type === RbdConfigurationType.milliseconds) { + result[config.name] = this.formatterService.toMilliseconds(control.value); + } else if (config.type === RbdConfigurationType.iops) { + result[config.name] = this.formatterService.toIops(control.value); + } else { + result[config.name] = control.value; + } + } + }); + + return result; + } + + /** + * Dynamically create form controls. + */ + private createConfigurationFormGroup() { + const configFormGroup = new CdFormGroup({}); + + this.rbdConfigurationService.getWritableOptionFields().forEach((c) => { + let control: FormControl; + if ( + c.type === RbdConfigurationType.milliseconds || + c.type === RbdConfigurationType.iops || + c.type === RbdConfigurationType.bps + ) { + control = new FormControl(0, Validators.min(0)); + } else { + throw new Error( + `Type ${c.type} is unknown, you may need to add it to RbdConfiguration class` + ); + } + configFormGroup.addControl(c.name, control); + }); + + return configFormGroup; + } + + /** + * Reset the value. The inherited value will be used instead. + */ + reset(optionName: string) { + const formControl = this.form.get('configuration').get(optionName); + if (formControl.disabled) { + formControl.setValue(formControl['previousValue'] || 0); + formControl.enable(); + if (!formControl['previousValue']) { + formControl.markAsPristine(); + } + } else { + formControl['previousValue'] = formControl.value; + formControl.setValue(null); + formControl.markAsDirty(); + formControl.disable(); + } + } + + isDisabled(optionName: string) { + return this.form.get('configuration').get(optionName).disabled; + } + + toggleSectionVisibility(className) { + this.sectionVisibility[className] = !this.sectionVisibility[className]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html new file mode 100644 index 000000000000..80d0b94731be --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.html @@ -0,0 +1,25 @@ + + + + +
+ Global + Image + Pool +
+
+ + +
+ {{ value | dimlessBinaryPerSecond }} + {{ value | milliseconds }} + {{ value | iops }} + {{ value }} +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.spec.ts new file mode 100644 index 000000000000..72b0114b37d0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.spec.ts @@ -0,0 +1,67 @@ +import { SimpleChange } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgxDatatableModule } from '@swimlane/ngx-datatable'; +import { ChartsModule } from 'ng2-charts'; +import { AlertModule } from 'ngx-bootstrap/alert'; + +import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; +import { ErrorPanelComponent } from '../../../shared/components/error-panel/error-panel.component'; +import { SparklineComponent } from '../../../shared/components/sparkline/sparkline.component'; +import { TableComponent } from '../../../shared/datatable/table/table.component'; +import { RbdConfigurationEntry } from '../../../shared/models/configuration'; +import { PipesModule } from '../../../shared/pipes/pipes.module'; +import { FormatterService } from '../../../shared/services/formatter.service'; +import { RbdConfigurationService } from '../../../shared/services/rbd-configuration.service'; +import { RbdConfigurationListComponent } from './rbd-configuration-list.component'; + +describe('RbdConfigurationListComponent', () => { + let component: RbdConfigurationListComponent; + let fixture: ComponentFixture; + + configureTestBed({ + imports: [ + FormsModule, + NgxDatatableModule, + RouterTestingModule, + AlertModule, + ChartsModule, + PipesModule + ], + declarations: [ + RbdConfigurationListComponent, + TableComponent, + ErrorPanelComponent, + SparklineComponent + ], + providers: [FormatterService, RbdConfigurationService, i18nProviders] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RbdConfigurationListComponent); + component = fixture.componentInstance; + component.data = []; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('filters options out which are not defined in RbdConfigurationService', () => { + const fakeOption = { name: 'foo', source: 0, value: '50' } as RbdConfigurationEntry; + const realOption = { + name: 'rbd_qos_read_iops_burst', + source: 0, + value: '50' + } as RbdConfigurationEntry; + + component.data = [fakeOption, realOption]; + component.ngOnChanges({ name: new SimpleChange(null, null, null) }); + + expect(component.data.length).toBe(1); + expect(component.data.pop()).toBe(realOption); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts new file mode 100644 index 000000000000..55389fd43a7e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-list/rbd-configuration-list.component.ts @@ -0,0 +1,66 @@ +import { + Component, + Input, + OnChanges, + OnInit, + SimpleChanges, + TemplateRef, + ViewChild +} from '@angular/core'; + +import { I18n } from '@ngx-translate/i18n-polyfill'; +import { CdTableColumn } from '../../../shared/models/cd-table-column'; +import { + RbdConfigurationEntry, + RbdConfigurationSourceField, + RbdConfigurationType +} from '../../../shared/models/configuration'; +import { FormatterService } from '../../../shared/services/formatter.service'; +import { RbdConfigurationService } from '../../../shared/services/rbd-configuration.service'; + +@Component({ + selector: 'cd-rbd-configuration-table', + templateUrl: './rbd-configuration-list.component.html', + styleUrls: ['./rbd-configuration-list.component.scss'] +}) +export class RbdConfigurationListComponent implements OnInit, OnChanges { + @Input() + data: RbdConfigurationEntry[]; + poolConfigurationColumns: CdTableColumn[]; + @ViewChild('configurationSourceTpl') + configurationSourceTpl: TemplateRef; + @ViewChild('configurationValueTpl') + configurationValueTpl: TemplateRef; + + readonly sourceField = RbdConfigurationSourceField; + readonly typeField = RbdConfigurationType; + + constructor( + public formatterService: FormatterService, + private rbdConfigurationService: RbdConfigurationService, + private i18n: I18n + ) {} + + ngOnInit() { + this.poolConfigurationColumns = [ + { prop: 'displayName', name: this.i18n('Name') }, + { prop: 'description', name: this.i18n('Description') }, + { prop: 'name', name: this.i18n('Key') }, + { prop: 'source', name: this.i18n('Source'), cellTemplate: this.configurationSourceTpl }, + { prop: 'value', name: this.i18n('Value'), cellTemplate: this.configurationValueTpl } + ]; + } + + ngOnChanges(changes: SimpleChanges): void { + if (!this.data) { + return; + } + // Filter settings out which are not listed in RbdConfigurationService + this.data = this.data.filter((row) => + this.rbdConfigurationService + .getOptionFields() + .map((o) => o.name) + .includes(row.name) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html index 28546d0d0485..889cab1f9a4b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html @@ -121,4 +121,19 @@ [poolName]="selectedItem.pool_name" [rbdName]="selectedItem.name"> + + + + + + + Image + + Global + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts index ac83e952cebe..44646595e812 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts @@ -6,6 +6,7 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip'; import { configureTestBed } from '../../../../testing/unit-test-helper'; import { SharedModule } from '../../../shared/shared.module'; +import { RbdConfigurationListComponent } from '../rbd-configuration-list/rbd-configuration-list.component'; import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component'; import { RbdDetailsComponent } from './rbd-details.component'; @@ -14,7 +15,7 @@ describe('RbdDetailsComponent', () => { let fixture: ComponentFixture; configureTestBed({ - declarations: [RbdDetailsComponent, RbdSnapshotListComponent], + declarations: [RbdDetailsComponent, RbdSnapshotListComponent, RbdConfigurationListComponent], imports: [SharedModule, TabsModule.forRoot(), TooltipModule.forRoot(), RouterTestingModule] }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts index 702416b96d78..a579a1d315e6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts @@ -1,6 +1,7 @@ -import { Component, Input, OnChanges } from '@angular/core'; +import { Component, Input, OnChanges, TemplateRef, ViewChild } from '@angular/core'; import { CdTableSelection } from '../../../shared/models/cd-table-selection'; +import { RbdFormModel } from '../rbd-form/rbd-form.model'; @Component({ selector: 'cd-rbd-details', @@ -10,7 +11,11 @@ import { CdTableSelection } from '../../../shared/models/cd-table-selection'; export class RbdDetailsComponent implements OnChanges { @Input() selection: CdTableSelection; - selectedItem: any; + selectedItem: RbdFormModel; + @Input() + images: any; + @ViewChild('poolConfigurationSourceTpl') + poolConfigurationSourceTpl: TemplateRef; constructor() {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-clone-request.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-clone-request.model.ts index 826d4cc3fa90..0c18352d6574 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-clone-request.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-clone-request.model.ts @@ -1,3 +1,5 @@ +import { RbdConfigurationEntry } from '../../../shared/models/configuration'; + export class RbdFormCloneRequestModel { child_pool_name: string; child_image_name: string; @@ -6,4 +8,5 @@ export class RbdFormCloneRequestModel { stripe_unit: number; stripe_count: number; data_pool: string; + configuration?: RbdConfigurationEntry[]; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-copy-request.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-copy-request.model.ts index 4553dc81c1fb..c1b290dca283 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-copy-request.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-copy-request.model.ts @@ -1,3 +1,5 @@ +import { RbdConfigurationEntry } from '../../../shared/models/configuration'; + export class RbdFormCopyRequestModel { dest_pool_name: string; dest_image_name: string; @@ -7,4 +9,5 @@ export class RbdFormCopyRequestModel { stripe_unit: number; stripe_count: number; data_pool: string; + configuration: RbdConfigurationEntry[]; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts index 39495630a4c5..37997e22d7d5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts @@ -1,5 +1,8 @@ +import { RbdConfigurationEntry } from '../../../shared/models/configuration'; + export class RbdFormEditRequestModel { name: string; size: number; features: Array = []; + configuration: RbdConfigurationEntry[]; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html index ec9729bfe293..5a0ef4ef0cd9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html @@ -7,7 +7,9 @@

- {mode, select, editing {Edit} cloning {Clone} copying {Copy} other {Add}} RBD + {mode, select, editing {Edit} cloning {Clone} copying {Copy} other {Add}} + RBD

@@ -211,82 +213,92 @@ i18n>Advanced...
-
+
- -
- -
- +
+ + + +
+ +
+ +
-
- -
-
@@ -295,7 +307,7 @@ - {mode, select, editing {Update} cloning {Clone} copying {Copy} other {Create}} RBD + {mode, select, editing {Update} cloning {Clone} copying {Copy} other {Create}} RBD
- -
- -
- - - - -
+
+ +
+ +
+ + + +
+
+ +
+ +
+
-