From f26b4b23fd695d7209e526e15edab847f742054c Mon Sep 17 00:00:00 2001 From: =?utf8?q?Stephan=20M=C3=BCller?= Date: Wed, 15 Jan 2020 14:39:13 +0100 Subject: [PATCH] mgr/dashboard: Crush rule modal MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Now a crush rule can be created and deleted through the pool form, similar to the ECP profile. The creation form is somewhat more intelligent as it checks the crush map to help create a usable rule, with only a few clicks through preselections. Fixes: https://tracker.ceph.com/issues/43260 Signed-off-by: Stephan Müller --- qa/tasks/mgr/dashboard/test_crush_rule.py | 89 ++++++ .../mgr/dashboard/controllers/crush_rule.py | 46 +++ .../crush-rule-form-modal.component.html | 124 ++++++++ .../crush-rule-form-modal.component.scss | 0 .../crush-rule-form-modal.component.spec.ts | 265 ++++++++++++++++++ .../crush-rule-form-modal.component.ts | 199 +++++++++++++ .../pool/pool-form/pool-form.component.html | 35 ++- .../pool-form/pool-form.component.spec.ts | 142 +++++++++- .../pool/pool-form/pool-form.component.ts | 115 ++++++-- .../frontend/src/app/ceph/pool/pool.module.ts | 4 +- .../app/shared/api/crush-rule.service.spec.ts | 47 ++++ .../src/app/shared/api/crush-rule.service.ts | 35 +++ .../src/app/shared/models/crush-node.ts | 17 ++ .../src/app/shared/models/crush-rule.ts | 7 + .../services/task-message.service.spec.ts | 21 ++ .../shared/services/task-message.service.ts | 17 ++ 16 files changed, 1136 insertions(+), 27 deletions(-) create mode 100644 qa/tasks/mgr/dashboard/test_crush_rule.py create mode 100644 src/pybind/mgr/dashboard/controllers/crush_rule.py create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts diff --git a/qa/tasks/mgr/dashboard/test_crush_rule.py b/qa/tasks/mgr/dashboard/test_crush_rule.py new file mode 100644 index 00000000000..a0bca63ff4a --- /dev/null +++ b/qa/tasks/mgr/dashboard/test_crush_rule.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +import six + +from .helper import DashboardTestCase, JObj, JList + + +class CrushRuleTest(DashboardTestCase): + + AUTH_ROLES = ['pool-manager'] + + rule_schema = JObj(sub_elems={ + 'max_size': int, + 'min_size': int, + 'rule_id': int, + 'rule_name': six.string_types, + 'ruleset': int, + 'steps': JList(JObj({}, allow_unknown=True)) + }, allow_unknown=True) + + def create_and_delete_rule(self, data): + name = data['name'] + # Creates rule + self._post('/api/crush_rule', data) + self.assertStatus(201) + # Makes sure rule exists + rule = self._get('/api/crush_rule/{}'.format(name)) + self.assertStatus(200) + self.assertSchemaBody(self.rule_schema) + self.assertEqual(rule['rule_name'], name) + # Deletes rule + self._delete('/api/crush_rule/{}'.format(name)) + self.assertStatus(204) + + @DashboardTestCase.RunAs('test', 'test', ['rgw-manager']) + def test_read_access_permissions(self): + self._get('/api/crush_rule') + self.assertStatus(403) + + @DashboardTestCase.RunAs('test', 'test', ['read-only']) + def test_write_access_permissions(self): + self._get('/api/crush_rule') + self.assertStatus(200) + data = {'name': 'some_rule', 'root': 'default', 'failure_domain': 'osd'} + self._post('/api/crush_rule', data) + self.assertStatus(403) + self._delete('/api/crush_rule/default') + self.assertStatus(403) + + @classmethod + def tearDownClass(cls): + super(CrushRuleTest, cls).tearDownClass() + cls._ceph_cmd(['osd', 'crush', 'rule', 'rm', 'some_rule']) + cls._ceph_cmd(['osd', 'crush', 'rule', 'rm', 'another_rule']) + + def test_list(self): + self._get('/api/crush_rule') + self.assertStatus(200) + self.assertSchemaBody(JList(self.rule_schema)) + + def test_create(self): + self.create_and_delete_rule({ + 'name': 'some_rule', + 'root': 'default', + 'failure_domain': 'osd' + }) + + @DashboardTestCase.RunAs('test', 'test', ['pool-manager', 'cluster-manager']) + def test_create_with_ssd(self): + data = self._get('/api/osd/0') + self.assertStatus(200) + device_class = data['osd_metadata']['default_device_class'] + self.create_and_delete_rule({ + 'name': 'another_rule', + 'root': 'default', + 'failure_domain': 'osd', + 'device_class': device_class + }) + + def test_crush_rule_info(self): + self._get('/ui-api/crush_rule/info') + self.assertStatus(200) + self.assertSchemaBody(JObj({ + 'names': JList(six.string_types), + 'nodes': JList(JObj({}, allow_unknown=True)) + })) + diff --git a/src/pybind/mgr/dashboard/controllers/crush_rule.py b/src/pybind/mgr/dashboard/controllers/crush_rule.py new file mode 100644 index 00000000000..6a2ffd8f770 --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/crush_rule.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +from cherrypy import NotFound + +from . import ApiController, RESTController, Endpoint, ReadPermission, UiApiController +from ..security import Scope +from ..services.ceph_service import CephService +from .. import mgr + + +@ApiController('/crush_rule', Scope.POOL) +class CrushRule(RESTController): + def list(self): + return mgr.get('osd_map_crush')['rules'] + + def get(self, name): + rules = mgr.get('osd_map_crush')['rules'] + for r in rules: + if r['rule_name'] == name: + return r + raise NotFound('No such crush rule') + + def create(self, name, root, failure_domain, device_class=None): + rule = { + 'name': name, + 'root': root, + 'type': failure_domain, + 'class': device_class + } + CephService.send_command('mon', 'osd crush rule create-replicated', **rule) + + def delete(self, name): + CephService.send_command('mon', 'osd crush rule rm', name=name) + + +@UiApiController('/crush_rule', Scope.POOL) +class CrushRuleUi(CrushRule): + @Endpoint() + @ReadPermission + def info(self): + '''Used for crush rule creation modal''' + return { + 'names': [r['rule_name'] for r in mgr.get('osd_map_crush')['rules']], + 'nodes': mgr.get('osd_map_tree')['nodes'] + } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html new file mode 100644 index 00000000000..c2e99387ffe --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.html @@ -0,0 +1,124 @@ + + {{ action | titlecase }} {{ resource | upperFirst }} + + +
+ + + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts new file mode 100644 index 00000000000..84d0d266e27 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.spec.ts @@ -0,0 +1,265 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation'; +import { BsModalRef } from 'ngx-bootstrap/modal'; +import { ToastrModule } from 'ngx-toastr'; +import { of } from 'rxjs'; + +import { + configureTestBed, + FixtureHelper, + FormHelper, + i18nProviders +} from '../../../../testing/unit-test-helper'; +import { CrushRuleService } from '../../../shared/api/crush-rule.service'; +import { CrushNode } from '../../../shared/models/crush-node'; +import { CrushRuleConfig } from '../../../shared/models/crush-rule'; +import { TaskWrapperService } from '../../../shared/services/task-wrapper.service'; +import { PoolModule } from '../pool.module'; +import { CrushRuleFormModalComponent } from './crush-rule-form-modal.component'; + +describe('CrushRuleFormComponent', () => { + let component: CrushRuleFormModalComponent; + let crushRuleService: CrushRuleService; + let fixture: ComponentFixture; + let formHelper: FormHelper; + let fixtureHelper: FixtureHelper; + let data: { names: string[]; nodes: CrushNode[] }; + + // Object contains mock functions + const mock = { + node: ( + name: string, + id: number, + type: string, + type_id: number, + children?: number[], + device_class?: string + ): CrushNode => { + return { name, type, type_id, id, children, device_class }; + }, + rule: ( + name: string, + root: string, + failure_domain: string, + device_class?: string + ): CrushRuleConfig => ({ + name, + root, + failure_domain, + device_class + }) + }; + + // Object contains functions to get something + const get = { + nodeByName: (name: string): CrushNode => data.nodes.find((node) => node.name === name), + nodesByNames: (names: string[]): CrushNode[] => names.map(get.nodeByName) + }; + + // Expects that are used frequently + const assert = { + failureDomains: (nodes: CrushNode[], types: string[]) => { + const expectation = {}; + types.forEach((type) => (expectation[type] = nodes.filter((node) => node.type === type))); + const keys = component.failureDomainKeys(); + expect(keys).toEqual(types); + keys.forEach((key) => { + expect(component.failureDomains[key].length).toBe(expectation[key].length); + }); + }, + formFieldValues: (root: CrushNode, failureDomain: string, device: string) => { + expect(component.form.value).toEqual({ + name: '', + root, + failure_domain: failureDomain, + device_class: device + }); + }, + valuesOnRootChange: ( + rootName: string, + expectedFailureDomain: string, + expectedDevice: string + ) => { + const node = get.nodeByName(rootName); + formHelper.setValue('root', node); + assert.formFieldValues(node, expectedFailureDomain, expectedDevice); + }, + creation: (rule: CrushRuleConfig) => { + formHelper.setValue('name', rule.name); + fixture.detectChanges(); + component.onSubmit(); + expect(crushRuleService.create).toHaveBeenCalledWith(rule); + } + }; + + configureTestBed({ + imports: [ + HttpClientTestingModule, + RouterTestingModule, + ToastrModule.forRoot(), + PoolModule, + NgBootstrapFormValidationModule.forRoot() + ], + providers: [CrushRuleService, BsModalRef, i18nProviders] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CrushRuleFormModalComponent); + fixtureHelper = new FixtureHelper(fixture); + component = fixture.componentInstance; + formHelper = new FormHelper(component.form); + crushRuleService = TestBed.get(CrushRuleService); + data = { + names: ['rule1', 'rule2'], + /** + * Create the following test crush map: + * > default + * --> ssd-host + * ----> 3x osd with ssd + * --> mix-host + * ----> hdd-rack + * ------> 2x osd-rack with hdd + * ----> ssd-rack + * ------> 2x osd-rack with ssd + */ + nodes: [ + // Root node + mock.node('default', -1, 'root', 11, [-2, -3]), + // SSD host + mock.node('ssd-host', -2, 'host', 1, [1, 0, 2]), + mock.node('osd.0', 0, 'osd', 0, undefined, 'ssd'), + mock.node('osd.1', 1, 'osd', 0, undefined, 'ssd'), + mock.node('osd.2', 2, 'osd', 0, undefined, 'ssd'), + // SSD and HDD mixed devices host + mock.node('mix-host', -3, 'host', 1, [-4, -5]), + // HDD rack + mock.node('hdd-rack', -4, 'rack', 3, [3, 4]), + mock.node('osd2.0', 3, 'osd-rack', 0, undefined, 'hdd'), + mock.node('osd2.1', 4, 'osd-rack', 0, undefined, 'hdd'), + // SSD rack + mock.node('ssd-rack', -5, 'rack', 3, [5, 6]), + mock.node('osd2.0', 5, 'osd-rack', 0, undefined, 'ssd'), + mock.node('osd2.1', 6, 'osd-rack', 0, undefined, 'ssd') + ] + }; + spyOn(crushRuleService, 'getInfo').and.callFake(() => of(data)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('calls listing to get rules on ngInit', () => { + expect(crushRuleService.getInfo).toHaveBeenCalled(); + expect(component.names.length).toBe(2); + expect(component['nodes'].length).toBe(12); + }); + + describe('lists', () => { + afterEach(() => { + // The available buckets should not change + expect(component.buckets).toEqual( + get.nodesByNames(['default', 'hdd-rack', 'mix-host', 'ssd-host', 'ssd-rack']) + ); + }); + + it('has the following lists after init', () => { + assert.failureDomains(data.nodes, ['host', 'osd', 'osd-rack', 'rack']); // Not root as root only exist once + expect(component.devices).toEqual(['hdd', 'ssd']); + }); + + it('has the following lists after selection of ssd-host', () => { + formHelper.setValue('root', get.nodeByName('ssd-host')); + assert.failureDomains(get.nodesByNames(['osd.0', 'osd.1', 'osd.2']), ['osd']); // Not host as it only exist once + expect(component.devices).toEqual(['ssd']); + }); + + it('has the following lists after selection of mix-host', () => { + formHelper.setValue('root', get.nodeByName('mix-host')); + expect(component.devices).toEqual(['hdd', 'ssd']); + assert.failureDomains( + get.nodesByNames(['hdd-rack', 'ssd-rack', 'osd2.0', 'osd2.1', 'osd2.0', 'osd2.1']), + ['osd-rack', 'rack'] + ); + }); + }); + + describe('selection', () => { + it('selects the first root after init automatically', () => { + assert.formFieldValues(get.nodeByName('default'), 'osd-rack', ''); + }); + + it('should select all values automatically by selecting "ssd-host" as root', () => { + assert.valuesOnRootChange('ssd-host', 'osd', 'ssd'); + }); + + it('selects automatically the most common failure domain', () => { + // Select mix-host as mix-host has multiple failure domains (osd-rack and rack) + assert.valuesOnRootChange('mix-host', 'osd-rack', ''); + }); + + it('should override automatic selections', () => { + assert.formFieldValues(get.nodeByName('default'), 'osd-rack', ''); + assert.valuesOnRootChange('ssd-host', 'osd', 'ssd'); + assert.valuesOnRootChange('mix-host', 'osd-rack', ''); + }); + + it('should not override manual selections if possible', () => { + formHelper.setValue('failure_domain', 'rack', true); + formHelper.setValue('device_class', 'ssd', true); + assert.valuesOnRootChange('mix-host', 'rack', 'ssd'); + }); + + it('should preselect device by domain selection', () => { + formHelper.setValue('failure_domain', 'osd', true); + assert.formFieldValues(get.nodeByName('default'), 'osd', 'ssd'); + }); + }); + + describe('form validation', () => { + it(`isn't valid if name is not set`, () => { + expect(component.form.invalid).toBeTruthy(); + formHelper.setValue('name', 'someProfileName'); + expect(component.form.valid).toBeTruthy(); + }); + + it('sets name invalid', () => { + component.names = ['awesomeProfileName']; + formHelper.expectErrorChange('name', 'awesomeProfileName', 'uniqueName'); + formHelper.expectErrorChange('name', 'some invalid text', 'pattern'); + formHelper.expectErrorChange('name', null, 'required'); + }); + + it(`should show all default form controls`, () => { + // name + // root (preselected(first root)) + // failure_domain (preselected=type that is most common) + // device_class (preselected=any if multiple or some type if only one device type) + fixtureHelper.expectIdElementsVisible( + ['name', 'root', 'failure_domain', 'device_class'], + true + ); + }); + }); + + describe('submission', () => { + beforeEach(() => { + const taskWrapper = TestBed.get(TaskWrapperService); + spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough(); + spyOn(crushRuleService, 'create').and.stub(); + }); + + it('creates a rule with only required fields', () => { + assert.creation(mock.rule('default-rule', 'default', 'osd-rack')); + }); + + it('creates a rule with all fields', () => { + assert.valuesOnRootChange('ssd-host', 'osd', 'ssd'); + assert.creation(mock.rule('ssd-host-rule', 'ssd-host', 'osd', 'ssd')); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts new file mode 100644 index 00000000000..58d7a450bb2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/crush-rule-form-modal/crush-rule-form-modal.component.ts @@ -0,0 +1,199 @@ +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { Validators } from '@angular/forms'; + +import { I18n } from '@ngx-translate/i18n-polyfill'; +import * as _ from 'lodash'; +import { BsModalRef } from 'ngx-bootstrap/modal'; + +import { CrushRuleService } from '../../../shared/api/crush-rule.service'; +import { ActionLabelsI18n } from '../../../shared/constants/app.constants'; +import { CdFormBuilder } from '../../../shared/forms/cd-form-builder'; +import { CdFormGroup } from '../../../shared/forms/cd-form-group'; +import { CdValidators } from '../../../shared/forms/cd-validators'; +import { CrushNode } from '../../../shared/models/crush-node'; +import { FinishedTask } from '../../../shared/models/finished-task'; +import { TaskWrapperService } from '../../../shared/services/task-wrapper.service'; + +@Component({ + selector: 'cd-crush-rule-form-modal', + templateUrl: './crush-rule-form-modal.component.html', + styleUrls: ['./crush-rule-form-modal.component.scss'] +}) +export class CrushRuleFormModalComponent implements OnInit { + @Output() + submitAction = new EventEmitter(); + + buckets: CrushNode[] = []; + failureDomains: { [type: string]: CrushNode[] } = {}; + devices: string[] = []; + tooltips = this.crushRuleService.formTooltips; + + form: CdFormGroup; + names: string[]; + action: string; + resource: string; + + private nodes: CrushNode[] = []; + private easyNodes: { [id: number]: CrushNode } = {}; + + constructor( + private formBuilder: CdFormBuilder, + public bsModalRef: BsModalRef, + private taskWrapper: TaskWrapperService, + private crushRuleService: CrushRuleService, + private i18n: I18n, + public actionLabels: ActionLabelsI18n + ) { + this.action = this.actionLabels.CREATE; + this.resource = this.i18n('Crush Rule'); + this.createForm(); + } + + createForm() { + this.form = this.formBuilder.group({ + // name: string + name: [ + '', + [ + Validators.required, + Validators.pattern('[A-Za-z0-9_-]+'), + CdValidators.custom( + 'uniqueName', + (value: any) => this.names && this.names.indexOf(value) !== -1 + ) + ] + ], + // root: CrushNode + root: null, // Replaced with first root + // failure_domain: string + failure_domain: '', // Replaced with most common type + // device_class: string + device_class: '' // Replaced with device type if only one exists beneath domain + }); + } + + ngOnInit() { + this.crushRuleService + .getInfo() + .subscribe(({ names, nodes }: { names: string[]; nodes: CrushNode[] }) => { + this.nodes = nodes; + nodes.forEach((node) => { + this.easyNodes[node.id] = node; + }); + this.buckets = _.sortBy(nodes.filter((n) => n.children), 'name'); + this.names = names; + this.preSelectRoot(); + }); + this.form.get('root').valueChanges.subscribe((root: CrushNode) => this.updateRoot(root)); + this.form + .get('failure_domain') + .valueChanges.subscribe((domain: string) => this.updateDevices(domain)); + } + + private preSelectRoot() { + const rootNode = this.nodes.find((node) => node.type === 'root'); + this.form.silentSet('root', rootNode); + this.updateRoot(rootNode); + } + + private updateRoot(rootNode: CrushNode) { + const nodes = this.getSubNodes(rootNode); + const domains = {}; + nodes.forEach((node) => { + if (!domains[node.type]) { + domains[node.type] = []; + } + domains[node.type].push(node); + }); + Object.keys(domains).forEach((type) => { + if (domains[type].length <= 1) { + delete domains[type]; + } + }); + this.failureDomains = domains; + this.updateFailureDomain(); + } + + private getSubNodes(node: CrushNode): CrushNode[] { + let subNodes = [node]; // Includes parent node + if (!node.children) { + return subNodes; + } + node.children.forEach((id) => { + const childNode = this.easyNodes[id]; + subNodes = subNodes.concat(this.getSubNodes(childNode)); + }); + return subNodes; + } + + private updateFailureDomain() { + let failureDomain = this.getIncludedCustomValue( + 'failure_domain', + Object.keys(this.failureDomains) + ); + if (failureDomain === '') { + failureDomain = this.setMostCommonDomain(); + } + this.updateDevices(failureDomain); + } + + private getIncludedCustomValue(controlName: string, includedIn: string[]) { + const control = this.form.get(controlName); + return control.dirty && includedIn.includes(control.value) ? control.value : ''; + } + + private setMostCommonDomain(): string { + let winner = { n: 0, type: '' }; + Object.keys(this.failureDomains).forEach((type) => { + const n = this.failureDomains[type].length; + if (winner.n < n) { + winner = { n, type }; + } + }); + this.form.silentSet('failure_domain', winner.type); + return winner.type; + } + + updateDevices(failureDomain: string) { + const subNodes = _.flatten( + this.failureDomains[failureDomain].map((node) => this.getSubNodes(node)) + ); + this.devices = _.uniq(subNodes.filter((n) => n.device_class).map((n) => n.device_class)).sort(); + const device = + this.devices.length === 1 + ? this.devices[0] + : this.getIncludedCustomValue('device_class', this.devices); + this.form.get('device_class').setValue(device); + } + + failureDomainKeys(): string[] { + return Object.keys(this.failureDomains).sort(); + } + + onSubmit() { + if (this.form.invalid) { + this.form.setErrors({ cdSubmitButton: true }); + return; + } + const rule = _.cloneDeep(this.form.value); + rule.root = rule.root.name; + if (rule.device_class === '') { + delete rule.device_class; + } + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('crushRule/create', rule), + call: this.crushRuleService.create(rule) + }) + .subscribe( + undefined, + () => { + this.form.setErrors({ cdSubmitButton: true }); + }, + () => { + this.bsModalRef.hide(); + this.submitAction.emit(rule); + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html index 7e87dc85768..3ccfd2a024b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html @@ -297,12 +297,30 @@ + + - + @@ -320,8 +338,23 @@ + + + Rule is not in use. + +
    +
  • + {{ pool }} +
  • +
+
+ This field is required! The rule can't be used in the current cluster as it has diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts index d4f8fc16e4d..8a9128ce1fb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts @@ -5,9 +5,10 @@ import { By } from '@angular/platform-browser'; import { ActivatedRoute, Router, Routes } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; +import * as _ from 'lodash'; import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation'; import { BsModalService } from 'ngx-bootstrap/modal'; -import { TabsModule } from 'ngx-bootstrap/tabs'; +import { TabsetComponent, TabsModule } from 'ngx-bootstrap/tabs'; import { ToastrModule } from 'ngx-toastr'; import { of } from 'rxjs'; @@ -18,6 +19,7 @@ import { i18nProviders } from '../../../../testing/unit-test-helper'; import { NotFoundComponent } from '../../../core/not-found/not-found.component'; +import { CrushRuleService } from '../../../shared/api/crush-rule.service'; import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service'; import { PoolService } from '../../../shared/api/pool.service'; import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; @@ -43,6 +45,7 @@ describe('PoolFormComponent', () => { let form: CdFormGroup; let router: Router; let ecpService: ErasureCodeProfileService; + let crushRuleService: CrushRuleService; const setPgNum = (pgs: number): AbstractControl => { const control = formHelper.setValue('pgNum', pgs); @@ -132,7 +135,8 @@ describe('PoolFormComponent', () => { compression_modes: ['none', 'passive'], crush_rules_replicated: [ createCrushRule({ id: 0, min: 2, max: 4, name: 'rep1', type: 'replicated' }), - createCrushRule({ id: 1, min: 3, max: 18, name: 'rep2', type: 'replicated' }) + createCrushRule({ id: 1, min: 3, max: 18, name: 'rep2', type: 'replicated' }), + createCrushRule({ id: 2, min: 1, max: 9, name: 'used_rule', type: 'replicated' }) ], crush_rules_erasure: [ createCrushRule({ id: 3, min: 1, max: 1, name: 'ecp1', type: 'erasure' }) @@ -140,8 +144,9 @@ describe('PoolFormComponent', () => { erasure_code_profiles: [ecp1], pg_autoscale_default_mode: 'off', pg_autoscale_modes: ['off', 'warn', 'on'], - pg_autoscale_config: { default: 'off', enum_values: ['on', 'warn', 'off'], value: [] }, - used_rules: {} + used_rules: { + used_rule: ['some.pool.uses.it'] + } }; }; @@ -183,6 +188,7 @@ describe('PoolFormComponent', () => { spyOn(poolService, 'getInfo').and.callFake(() => of(infoReturn)); ecpService = TestBed.get(ErasureCodeProfileService); + crushRuleService = TestBed.get(CrushRuleService); router = TestBed.get(Router); navigationSpy = spyOn(router, 'navigate').and.stub(); @@ -305,6 +311,7 @@ describe('PoolFormComponent', () => { it('validates crushRule with multiple crush rules', () => { formHelper.expectValidChange('poolType', 'replicated'); + form.get('crushRule').updateValueAndValidity(); formHelper.expectError('crushRule', 'required'); // As multiple rules exist formHelper.expectErrorChange('crushRule', { min_size: 20 }, 'tooFewOsds'); }); @@ -314,7 +321,6 @@ describe('PoolFormComponent', () => { setUpPoolComponent(); formHelper.expectValidChange('poolType', 'replicated'); formHelper.expectValid('crushRule'); - formHelper.expectErrorChange('crushRule', { min_size: 20 }, 'tooFewOsds'); }); it('validates size', () => { @@ -483,14 +489,14 @@ describe('PoolFormComponent', () => { }); it('has no effect if pool type is not set', () => { - component['rulesChange'](''); + component['poolTypeChange'](''); expect(component.current.rules).toEqual([]); }); it('shows all replicated rules when pool type is "replicated"', () => { formHelper.setValue('poolType', 'replicated'); expect(component.current.rules).toEqual(component.info.crush_rules_replicated); - expect(component.current.rules.length).toBe(2); + expect(component.current.rules.length).toBe(3); }); it('shows all erasure code rules when pool type is "erasure"', () => { @@ -783,13 +789,13 @@ describe('PoolFormComponent', () => { }); describe('crushRule', () => { - const selectRuleById = (n: number) => { + const selectRuleByIndex = (n: number) => { formHelper.setValue('crushRule', component.info.crush_rules_replicated[n]); }; beforeEach(() => { formHelper.setValue('poolType', 'replicated'); - selectRuleById(0); + selectRuleByIndex(0); fixture.detectChanges(); }); @@ -806,6 +812,124 @@ describe('PoolFormComponent', () => { expect(infoButton.classes['active']).toBeTruthy(); fixtureHelper.expectIdElementsVisible(['crushRule', 'crush-info-block'], true); }); + + it('should know which rules are in use', () => { + selectRuleByIndex(2); + expect(component.crushUsage).toEqual(['some.pool.uses.it']); + }); + + describe('crush rule deletion', () => { + let taskWrapper: TaskWrapperService; + let deletion: CriticalConfirmationModalComponent; + let deleteSpy: jasmine.Spy; + let modalSpy: jasmine.Spy; + + const callDeletion = () => { + component.deleteCrushRule(); + deletion.submitActionObservable(); + }; + + const callDeletionWithRuleByIndex = (index: number) => { + deleteSpy.calls.reset(); + selectRuleByIndex(index); + callDeletion(); + }; + + const expectSuccessfulDeletion = (name: string) => { + expect(crushRuleService.delete).toHaveBeenCalledWith(name); + expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({ + task: { + name: 'crushRule/delete', + metadata: { + name: name + } + }, + call: undefined // because of stub + }); + }; + + beforeEach(() => { + modalSpy = spyOn(TestBed.get(BsModalService), 'show').and.callFake( + (deletionClass, config) => { + deletion = Object.assign(new deletionClass(), config.initialState); + return { + content: deletion + }; + } + ); + deleteSpy = spyOn(crushRuleService, 'delete').and.callFake((name) => { + const rules = infoReturn.crush_rules_replicated; + const index = _.findIndex(rules, (rule) => rule.rule_name === name); + rules.splice(index, 1); + }); + taskWrapper = TestBed.get(TaskWrapperService); + spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough(); + }); + + describe('with unused rule', () => { + beforeEach(() => { + callDeletionWithRuleByIndex(0); + }); + + it('should have called delete', () => { + expectSuccessfulDeletion('rep1'); + }); + + it('should not open the tooltip nor the crush info', () => { + expect(component.crushDeletionBtn.isOpen).toBe(false); + expect(component.data.crushInfo).toBe(false); + }); + + it('should reload the rules after deletion', () => { + const expected = infoReturn.crush_rules_replicated; + const currentRules = component.current.rules; + expect(currentRules.length).toBe(expected.length); + expect(currentRules).toEqual(expected); + }); + }); + + describe('rule in use', () => { + beforeEach(() => { + spyOn(global, 'setTimeout').and.callFake((fn: Function) => fn()); + component.crushInfoTabs = { tabs: [{}, {}, {}] } as TabsetComponent; // Mock it + deleteSpy.calls.reset(); + selectRuleByIndex(2); + component.deleteCrushRule(); + }); + + it('should not have called delete and opened the tooltip', () => { + expect(crushRuleService.delete).not.toHaveBeenCalled(); + expect(component.crushDeletionBtn.isOpen).toBe(true); + expect(component.data.crushInfo).toBe(true); + }); + + it('should open the third crush info tab', () => { + expect(component.crushInfoTabs).toEqual({ + tabs: [{}, {}, { active: true }] + } as TabsetComponent); + }); + + it('should hide the tooltip when clicking on delete again', () => { + component.deleteCrushRule(); + expect(component.crushDeletionBtn.isOpen).toBe(false); + }); + + it('should hide the tooltip when clicking on add', () => { + modalSpy.and.callFake((): any => ({ + content: { + submitAction: of('someRule') + } + })); + component.addCrushRule(); + expect(component.crushDeletionBtn.isOpen).toBe(false); + }); + + it('should hide the tooltip when changing the crush rule', () => { + selectRuleByIndex(0); + expect(component.crushDeletionBtn.isOpen).toBe(false); + }); + }); + }); }); describe('erasure code profile', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts index 92bb9522db3..25edf591d33 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts @@ -5,8 +5,11 @@ import { ActivatedRoute, Router } from '@angular/router'; import { I18n } from '@ngx-translate/i18n-polyfill'; import * as _ from 'lodash'; import { BsModalService } from 'ngx-bootstrap/modal'; +import { TabsetComponent } from 'ngx-bootstrap/tabs'; +import { TooltipDirective } from 'ngx-bootstrap/tooltip'; import { Subscription } from 'rxjs'; +import { CrushRuleService } from '../../../shared/api/crush-rule.service'; import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service'; import { PoolService } from '../../../shared/api/pool.service'; import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; @@ -19,7 +22,7 @@ import { RbdConfigurationEntry, RbdConfigurationSourceField } from '../../../shared/models/configuration'; -import { CrushRule } from '../../../shared/models/crush-rule'; +import { CrushRule, CrushRuleConfig } from '../../../shared/models/crush-rule'; import { CrushStep } from '../../../shared/models/crush-step'; import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile'; import { FinishedTask } from '../../../shared/models/finished-task'; @@ -29,6 +32,7 @@ import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe'; import { AuthStorageService } from '../../../shared/services/auth-storage.service'; import { FormatterService } from '../../../shared/services/formatter.service'; import { TaskWrapperService } from '../../../shared/services/task-wrapper.service'; +import { CrushRuleFormModalComponent } from '../crush-rule-form-modal/crush-rule-form-modal.component'; import { ErasureCodeProfileFormComponent } from '../erasure-code-profile-form/erasure-code-profile-form.component'; import { Pool } from '../pool'; import { PoolFormData } from './pool-form-data'; @@ -48,6 +52,9 @@ interface FormFieldDescription { styleUrls: ['./pool-form.component.scss'] }) export class PoolFormComponent implements OnInit { + @ViewChild('crushInfoTabs', { static: false }) crushInfoTabs: TabsetComponent; + @ViewChild('crushDeletionBtn', { static: false }) crushDeletionBtn: TooltipDirective; + permission: Permission; form: CdFormGroup; ecProfiles: ErasureCodeProfile[]; @@ -70,6 +77,7 @@ export class PoolFormComponent implements OnInit { resource: string; icons = Icons; pgAutoscaleModes: string[]; + crushUsage: string[] = undefined; // Will only be set if a rule is used by some pool private modalSubscription: Subscription; @@ -84,6 +92,7 @@ export class PoolFormComponent implements OnInit { private bsModalService: BsModalService, private taskWrapper: TaskWrapperService, private ecpService: ErasureCodeProfileService, + private crushRuleService: CrushRuleService, private i18n: I18n, public actionLabels: ActionLabelsI18n ) { @@ -196,13 +205,26 @@ export class PoolFormComponent implements OnInit { this.ecProfiles = ecProfiles; } + /** + * Used to update the crush rule or erasure code profile listings. + * + * If only one rule or profile exists it will be selected. + * If nothing exists null will be selected. + * If more than one rule or profile exists the listing will be enabled, + * otherwise disabled. + */ private setListControlStatus(controlName: string, arr: any[]) { const control = this.form.get(controlName); - if (arr.length === 1) { + const value = control.value; + if (arr.length === 1 && (!value || !_.isEqual(value, arr[0]))) { control.setValue(arr[0]); + } else if (arr.length === 0 && value) { + control.setValue(null); } if (arr.length <= 1) { - control.disable(); + if (control.enabled) { + control.disable(); + } } else if (control.disabled) { control.enable(); } @@ -229,7 +251,7 @@ export class PoolFormComponent implements OnInit { initialData: pool.configuration, sourceType: RbdConfigurationSourceField.pool }); - this.rulesChange(pool.type); + this.poolTypeChange(pool.type); const rules = this.info.crush_rules_replicated.concat(this.info.crush_rules_erasure); const dataMap = { name: pool.pool_name, @@ -300,10 +322,17 @@ export class PoolFormComponent implements OnInit { private listenToChangesDuringAdd() { this.form.get('poolType').valueChanges.subscribe((poolType) => { - this.rulesChange(poolType); + this.poolTypeChange(poolType); }); - this.form.get('crushRule').valueChanges.subscribe(() => { + this.form.get('crushRule').valueChanges.subscribe((rule) => { // The crush rule can only be changed if type 'replicated' is set. + if (this.crushDeletionBtn && this.crushDeletionBtn.isOpen) { + this.crushDeletionBtn.hide(); + } + if (!rule) { + return; + } + this.crushRuleIsUsedBy(rule.rule_name); this.replicatedRuleChange(); this.pgCalc(); }); @@ -328,7 +357,7 @@ export class PoolFormComponent implements OnInit { }); } - private rulesChange(poolType: string) { + private poolTypeChange(poolType: string) { if (poolType === 'replicated') { this.setTypeBooleans(true, false); } else if (poolType === 'erasure') { @@ -345,15 +374,8 @@ export class PoolFormComponent implements OnInit { if (this.editing) { return; } - const control = this.form.get('crushRule'); - if (this.isReplicated && !control.value) { - if (rules.length === 1) { - control.setValue(rules[0]); - control.disable(); - } else { - control.setValue(null); - control.enable(); - } + if (this.isReplicated) { + this.setListControlStatus('crushRule', rules); } this.replicatedRuleChange(); this.pgCalc(); @@ -548,6 +570,67 @@ export class PoolFormComponent implements OnInit { }); } + addCrushRule() { + if (this.crushDeletionBtn.isOpen) { + this.crushDeletionBtn.hide(); + } + const modalRef = this.bsModalService.show(CrushRuleFormModalComponent); + modalRef.content.submitAction.subscribe((rule: CrushRuleConfig) => { + this.reloadCrushRules(rule.name); + }); + } + + private reloadCrushRules(ruleName?: string) { + if (this.modalSubscription) { + this.modalSubscription.unsubscribe(); + } + this.poolService.getInfo().subscribe((info: PoolFormInfo) => { + this.initInfo(info); + this.poolTypeChange('replicated'); + if (!ruleName) { + return; + } + const newRule = this.info.crush_rules_replicated.find((rule) => rule.rule_name === ruleName); + if (newRule) { + this.form.get('crushRule').setValue(newRule); + } + }); + } + + deleteCrushRule() { + const rule = this.form.getValue('crushRule'); + if (!rule) { + return; + } + if (this.crushUsage) { + this.crushDeletionBtn.toggle(); + this.data.crushInfo = true; + setTimeout(() => { + if (this.crushInfoTabs) { + this.crushInfoTabs.tabs[2].active = true; + } + }, 50); + return; + } + const name = rule.rule_name; + this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadCrushRules()); + this.modalService.show(CriticalConfirmationModalComponent, { + initialState: { + itemDescription: this.i18n('crush rule'), + itemNames: [name], + submitActionObservable: () => + this.taskWrapper.wrapTaskAroundCall({ + task: new FinishedTask('crushRule/delete', { name: name }), + call: this.crushRuleService.delete(name) + }) + } + }); + } + + crushRuleIsUsedBy(ruleName: string) { + this.crushUsage = ruleName ? this.info.used_rules[ruleName] : undefined; + } + submit() { if (this.form.invalid) { this.form.setErrors({ cdSubmitButton: true }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts index dc47e3c6466..ee768f8e60d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts @@ -13,6 +13,7 @@ import { ActionLabels, URLVerbs } from '../../shared/constants/app.constants'; import { SharedModule } from '../../shared/shared.module'; import { BlockModule } from '../block/block.module'; import { CephSharedModule } from '../shared/ceph-shared.module'; +import { CrushRuleFormModalComponent } from './crush-rule-form-modal/crush-rule-form-modal.component'; import { ErasureCodeProfileFormComponent } from './erasure-code-profile-form/erasure-code-profile-form.component'; import { PoolDetailsComponent } from './pool-details/pool-details.component'; import { PoolFormComponent } from './pool-form/pool-form.component'; @@ -37,9 +38,10 @@ import { PoolListComponent } from './pool-list/pool-list.component'; PoolListComponent, PoolFormComponent, ErasureCodeProfileFormComponent, + CrushRuleFormModalComponent, PoolDetailsComponent ], - entryComponents: [ErasureCodeProfileFormComponent] + entryComponents: [CrushRuleFormModalComponent, ErasureCodeProfileFormComponent] }) export class PoolModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts new file mode 100644 index 00000000000..4a200941bc7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts @@ -0,0 +1,47 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed, i18nProviders } from '../../../testing/unit-test-helper'; +import { CrushRuleService } from './crush-rule.service'; + +describe('CrushRuleService', () => { + let service: CrushRuleService; + let httpTesting: HttpTestingController; + const apiPath = 'api/crush_rule'; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [CrushRuleService, i18nProviders] + }); + + beforeEach(() => { + service = TestBed.get(CrushRuleService); + httpTesting = TestBed.get(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call create', () => { + service.create({ root: 'default', name: 'someRule', failure_domain: 'osd' }).subscribe(); + const req = httpTesting.expectOne(apiPath); + expect(req.request.method).toBe('POST'); + }); + + it('should call delete', () => { + service.delete('test').subscribe(); + const req = httpTesting.expectOne(`${apiPath}/test`); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call getInfo', () => { + service.getInfo().subscribe(); + const req = httpTesting.expectOne(`ui-${apiPath}/info`); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts new file mode 100644 index 00000000000..506fa23d4e6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts @@ -0,0 +1,35 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { I18n } from '@ngx-translate/i18n-polyfill'; + +import { CrushRuleConfig } from '../models/crush-rule'; +import { ApiModule } from './api.module'; + +@Injectable({ + providedIn: ApiModule +}) +export class CrushRuleService { + apiPath = 'api/crush_rule'; + + formTooltips = { + // Copied from /doc/rados/operations/crush-map.rst + root: this.i18n(`The name of the node under which data should be placed.`), + failure_domain: this.i18n(`The type of CRUSH nodes across which we should separate replicas.`), + device_class: this.i18n(`The device class data should be placed on.`) + }; + + constructor(private http: HttpClient, private i18n: I18n) {} + + create(rule: CrushRuleConfig) { + return this.http.post(this.apiPath, rule, { observe: 'response' }); + } + + delete(name: string) { + return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' }); + } + + getInfo() { + return this.http.get(`ui-${this.apiPath}/info`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts new file mode 100644 index 00000000000..a8c8288b61b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts @@ -0,0 +1,17 @@ +export class CrushNode { + id: number; + name: string; + type: string; + type_id: number; + // For nodes with leafs (Buckets) + children?: number[]; // Holds node id's of children + // For non root nodes + pool_weights?: object; + // For leafs (Devices) + device_class?: string; + crush_weight?: number; + exists?: number; + primary_affinity?: number; + reweight?: number; + status?: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts index ef0508508ff..c7c6d56ca0b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts @@ -8,3 +8,10 @@ export class CrushRule { ruleset: number; steps: CrushStep[]; } + +export class CrushRuleConfig { + root: string; // The name of the node under which data should be placed. + name: string; + failure_domain: string; // The type of CRUSH nodes across which we should separate replicas. + device_class?: string; // The device class data should be placed on. +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts index a88bfbf1476..5910dbf310a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts @@ -126,6 +126,27 @@ describe('TaskManagerMessageService', () => { }); }); + describe('crush rule tasks', () => { + beforeEach(() => { + const metadata = { + name: 'someRuleName' + }; + defaultMsg = `crush rule '${metadata.name}'`; + finishedTask.metadata = metadata; + }); + + it('tests crushRule/create messages', () => { + finishedTask.name = 'crushRule/create'; + testCreate(defaultMsg); + testErrorCode(17, `Name is already used by ${defaultMsg}.`); + }); + + it('tests crushRule/delete messages', () => { + finishedTask.name = 'crushRule/delete'; + testDelete(defaultMsg); + }); + }); + describe('rbd tasks', () => { let metadata: Record; let childMsg: string; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index 51d1ff06dfe..c2e44aa376a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -201,6 +201,19 @@ export class TaskMessageService { 'ecp/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => this.ecp(metadata) ), + // Crush rule tasks + 'crushRule/create': this.newTaskMessage( + this.commonOperations.create, + (metadata) => this.crushRule(metadata), + (metadata) => ({ + '17': this.i18n('Name is already used by {{name}}.', { + name: this.crushRule(metadata) + }) + }) + ), + 'crushRule/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.crushRule(metadata) + ), // RBD tasks 'rbd/create': this.newTaskMessage( this.commonOperations.create, @@ -429,6 +442,10 @@ export class TaskMessageService { return this.i18n(`erasure code profile '{{name}}'`, { name: metadata.name }); } + crushRule(metadata: any) { + return this.i18n(`crush rule '{{name}}'`, { name: metadata.name }); + } + iscsiTarget(metadata: any) { return this.i18n(`target '{{target_iqn}}'`, { target_iqn: metadata.target_iqn }); } -- 2.47.3