]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Use of crush node class in ECP modal
authorStephan Müller <smueller@suse.com>
Tue, 7 Apr 2020 14:02:28 +0000 (16:02 +0200)
committerStephan Müller <smueller@suse.com>
Tue, 28 Apr 2020 15:45:28 +0000 (17:45 +0200)
Now the crush node class preselects root, failure domain and device
class in the erasure code profile modal.

Like for crush rule, now also if you try to delete an used ECP you can't
and the info box will show you, what pool is using the profile.

Fixes: https://tracker.ceph.com/issues/44621
Signed-off-by: Stephan Müller <smueller@suse.com>
qa/tasks/mgr/dashboard/test_erasure_code_profile.py
qa/tasks/mgr/dashboard/test_pool.py
src/pybind/mgr/dashboard/controllers/erasure_code_profile.py
src/pybind/mgr/dashboard/controllers/pool.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-form-info.ts

index 111e37c7e3e66dfdc71017341122de5bcb67cb53..12e061777fc28170e2a946058257ec6282e0f05c 100644 (file)
@@ -102,9 +102,8 @@ class ECPTest(DashboardTestCase):
         self._get('/ui-api/erasure_code_profile/info')
         self.assertSchemaBody(JObj({
             'names': JList(six.string_types),
-            'failure_domains': JList(six.string_types),
             'plugins': JList(six.string_types),
-            'devices': JList(six.string_types),
             'directory': six.string_types,
+            'nodes': JList(JObj({}, allow_unknown=True))
         }))
 
index 969318d2a94b3b79496cf1396cf7e992ecd05093..2a49dff1ac131fa7889a78654719f18c57614321 100644 (file)
@@ -414,4 +414,5 @@ class PoolTest(DashboardTestCase):
             'pg_autoscale_modes': JList(six.string_types),
             'erasure_code_profiles': JList(JObj({}, allow_unknown=True)),
             'used_rules': JObj({}, allow_unknown=True),
+            'used_profiles': JObj({}, allow_unknown=True),
         }))
index ca63ba286a4b1a3c7bee597a21ff12d1e2e70b05..c4cc867220d6ca07f8a7d4c0d7500e09cde9530c 100644 (file)
@@ -11,10 +11,10 @@ from .. import mgr
 
 @ApiController('/erasure_code_profile', Scope.POOL)
 class ErasureCodeProfile(RESTController):
-    '''
+    """
     create() supports additional key-value arguments that are passed to the
     ECP plugin.
-    '''
+    """
 
     def list(self):
         return CephService.get_erasure_code_profiles()
@@ -40,15 +40,15 @@ class ErasureCodeProfileUi(ErasureCodeProfile):
     @Endpoint()
     @ReadPermission
     def info(self):
-        '''Used for profile creation and editing'''
+        """
+        Used for profile creation and editing
+        """
         config = mgr.get('config')
-        osd_map_crush = mgr.get('osd_map_crush')
         return {
             # Because 'shec' is experimental it's not included
             'plugins': config['osd_erasure_code_plugins'].split() + ['shec'],
             'directory': config['erasure_code_dir'],
-            'devices': list({device['class'] for device in osd_map_crush['devices']}),
-            'failure_domains': [domain['name'] for domain in osd_map_crush['types']],
+            'nodes': mgr.get('osd_map_tree')['nodes'],
             'names': [name for name, _ in
                       mgr.get('osd_map').get('erasure_code_profiles', {}).items()]
         }
index 275c59c44a9207e263b483181226472ee5278c88..945a82a177a498adc8b65c549de18e5d17f7372e 100644 (file)
@@ -229,16 +229,23 @@ class PoolUi(Pool):
                     for o in options
                     if o['name'] == conf_name][0]
 
+        profiles = CephService.get_erasure_code_profiles()
         used_rules = {}
+        used_profiles = {}
         pool_names = []
         for p in self._pool_list():
             name = p['pool_name']
-            rule = p['crush_rule']
             pool_names.append(name)
+            rule = p['crush_rule']
             if rule in used_rules:
                 used_rules[rule].append(name)
             else:
                 used_rules[rule] = [name]
+            profile = p['erasure_code_profile']
+            if profile in used_profiles:
+                used_profiles[profile].append(name)
+            else:
+                used_profiles[profile] = [name]
 
         mgr_config = mgr.get('config')
         return {
@@ -252,6 +259,7 @@ class PoolUi(Pool):
             "compression_modes": get_config_option_enum('bluestore_compression_mode'),
             "pg_autoscale_default_mode": mgr_config['osd_pool_default_pg_autoscale_mode'],
             "pg_autoscale_modes": get_config_option_enum('osd_pool_default_pg_autoscale_mode'),
-            "erasure_code_profiles": CephService.get_erasure_code_profiles(),
-            "used_rules": used_rules
+            "erasure_code_profiles": profiles,
+            "used_rules": used_rules,
+            "used_profiles": used_profiles,
         }
index 985f6a3fa551d9e6a8402d45a83308b4127298e4..89b383e898781ec40e65e537b6b2b92a31364862 100644 (file)
               <option *ngIf="!failureDomains"
                       ngValue=""
                       i18n>Loading...</option>
-              <option *ngFor="let domain of failureDomains"
+              <option *ngFor="let domain of failureDomainKeys"
                       [ngValue]="domain">
-                {{ domain }}
+                {{ domain }} ( {{failureDomains[domain].length}} )
               </option>
             </select>
           </div>
               <option *ngIf="!failureDomains"
                       ngValue=""
                       i18n>Loading...</option>
-              <option *ngIf="failureDomains && failureDomains.length > 0"
+              <option *ngIf="failureDomainKeys.length > 0"
                       ngValue=""
                       i18n>None</option>
-              <option *ngFor="let domain of failureDomains"
+              <option *ngFor="let domain of failureDomainKeys"
                       [ngValue]="domain">
-                {{ domain }}
+                {{ domain }} ( {{failureDomains[domain].length}} )
               </option>
             </select>
           </div>
             </cd-helper>
           </label>
           <div class="cd-col-form-input">
-            <input type="text"
-                   id="crushRoot"
-                   name="crushRoot"
-                   class="form-control"
-                   placeholder="root..."
-                   formControlName="crushRoot">
+            <select class="form-control custom-select"
+                    id="crushRoot"
+                    name="crushRoot"
+                    formControlName="crushRoot">
+              <option *ngIf="!buckets"
+                      ngValue=""
+                      i18n>Loading...</option>
+              <option *ngFor="let bucket of buckets"
+                      [ngValue]="bucket">
+                {{ bucket.name }}
+              </option>
+            </select>
           </div>
         </div>
 
                     name="crushDeviceClass"
                     formControlName="crushDeviceClass">
               <option ngValue=""
-                      i18n>any</option>
+                      i18n>Let Ceph decide</option>
               <option *ngFor="let deviceClass of devices"
                       [ngValue]="deviceClass">
                 {{ deviceClass }}
               </option>
             </select>
+            <span class="form-text text-muted"
+                  i18n>Available OSDs: {{deviceCount}}</span>
           </div>
         </div>
 
index 0d4ce97a210165c8673c00f16fd7263326f77180..1886ebd730fea5cf3e967fc42a1925aad7196a7a 100644 (file)
@@ -15,6 +15,7 @@ import {
   i18nProviders
 } from '../../../../testing/unit-test-helper';
 import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
+import { CrushNode } from '../../../shared/models/crush-node';
 import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile';
 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
 import { PoolModule } from '../pool.module';
@@ -28,6 +29,20 @@ describe('ErasureCodeProfileFormModalComponent', () => {
   let fixtureHelper: FixtureHelper;
   let data: {};
 
+  // 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 };
+    }
+  };
+
   configureTestBed({
     imports: [
       HttpClientTestingModule,
@@ -46,10 +61,44 @@ describe('ErasureCodeProfileFormModalComponent', () => {
     formHelper = new FormHelper(component.form);
     ecpService = TestBed.get(ErasureCodeProfileService);
     data = {
-      failure_domains: ['host', 'osd'],
       plugins: ['isa', 'jerasure', 'shec', 'lrc'],
       names: ['ecp1', 'ecp2'],
-      devices: ['ssd', 'hdd']
+      /**
+       * 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, 5, 6, 7]),
+        mock.node('osd2.0', 3, 'osd-rack', 0, undefined, 'hdd'),
+        mock.node('osd2.1', 4, 'osd-rack', 0, undefined, 'hdd'),
+        mock.node('osd2.2', 5, 'osd-rack', 0, undefined, 'hdd'),
+        mock.node('osd2.3', 6, 'osd-rack', 0, undefined, 'hdd'),
+        mock.node('osd2.4', 7, 'osd-rack', 0, undefined, 'hdd'),
+        // SSD rack
+        mock.node('ssd-rack', -5, 'rack', 3, [8, 9, 10, 11, 12]),
+        mock.node('osd3.0', 8, 'osd-rack', 0, undefined, 'ssd'),
+        mock.node('osd3.1', 9, 'osd-rack', 0, undefined, 'ssd'),
+        mock.node('osd3.2', 10, 'osd-rack', 0, undefined, 'ssd'),
+        mock.node('osd3.3', 11, 'osd-rack', 0, undefined, 'ssd'),
+        mock.node('osd3.4', 12, 'osd-rack', 0, undefined, 'ssd')
+      ]
     };
     spyOn(ecpService, 'getInfo').and.callFake(() => of(data));
     fixture.detectChanges();
@@ -189,15 +238,27 @@ describe('ErasureCodeProfileFormModalComponent', () => {
 
   describe('submission', () => {
     let ecp: ErasureCodeProfile;
+    let submittedEcp: ErasureCodeProfile;
 
     const testCreation = () => {
       fixture.detectChanges();
       component.onSubmit();
-      expect(ecpService.create).toHaveBeenCalledWith(ecp);
+      expect(ecpService.create).toHaveBeenCalledWith(submittedEcp);
+    };
+
+    const ecpChange = (attribute: string, value: string | number) => {
+      ecp[attribute] = value;
+      submittedEcp[attribute] = value;
     };
 
     beforeEach(() => {
       ecp = new ErasureCodeProfile();
+      submittedEcp = new ErasureCodeProfile();
+      submittedEcp['crush-root'] = 'default';
+      submittedEcp['crush-failure-domain'] = 'osd-rack';
+      submittedEcp['packetsize'] = 2048;
+      submittedEcp['technique'] = 'reed_sol_van';
+
       const taskWrapper = TestBed.get(TaskWrapperService);
       spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
       spyOn(ecpService, 'create').and.stub();
@@ -205,37 +266,35 @@ describe('ErasureCodeProfileFormModalComponent', () => {
 
     describe(`'jerasure' usage`, () => {
       beforeEach(() => {
-        ecp.name = 'jerasureProfile';
+        submittedEcp['plugin'] = 'jerasure';
+        ecpChange('name', 'jerasureProfile');
+        submittedEcp.k = 4;
+        submittedEcp.m = 2;
       });
 
       it('should be able to create a profile with only required fields', () => {
         formHelper.setMultipleValues(ecp, true);
-        ecp.k = 4;
-        ecp.m = 2;
         testCreation();
       });
 
       it(`does not create with missing 'k' or invalid form`, () => {
-        ecp.k = 0;
+        ecpChange('k', 0);
         formHelper.setMultipleValues(ecp, true);
         component.onSubmit();
         expect(ecpService.create).not.toHaveBeenCalled();
       });
 
       it('should be able to create a profile with m, k, name, directory and packetSize', () => {
-        ecp.m = 3;
-        ecp.directory = '/different/ecp/path';
+        ecpChange('m', 3);
+        ecpChange('directory', '/different/ecp/path');
         formHelper.setMultipleValues(ecp, true);
-        ecp.k = 4;
         formHelper.setValue('packetSize', 8192, true);
-        ecp.packetsize = 8192;
+        ecpChange('packetsize', 8192);
         testCreation();
       });
 
       it('should not send the profile with unsupported fields', () => {
         formHelper.setMultipleValues(ecp, true);
-        ecp.k = 4;
-        ecp.m = 2;
         formHelper.setValue('crushLocality', 'osd', true);
         testCreation();
       });
@@ -243,8 +302,11 @@ describe('ErasureCodeProfileFormModalComponent', () => {
 
     describe(`'isa' usage`, () => {
       beforeEach(() => {
-        ecp.name = 'isaProfile';
-        ecp.plugin = 'isa';
+        ecpChange('name', 'isaProfile');
+        ecpChange('plugin', 'isa');
+        submittedEcp.k = 7;
+        submittedEcp.m = 3;
+        delete submittedEcp.packetsize;
       });
 
       it('should be able to create a profile with only plugin and name', () => {
@@ -253,10 +315,11 @@ describe('ErasureCodeProfileFormModalComponent', () => {
       });
 
       it('should send profile with plugin, name, failure domain and technique only', () => {
-        ecp.technique = 'cauchy';
+        ecpChange('technique', 'cauchy');
         formHelper.setMultipleValues(ecp, true);
         formHelper.setValue('crushFailureDomain', 'osd', true);
-        ecp['crush-failure-domain'] = 'osd';
+        submittedEcp['crush-failure-domain'] = 'osd';
+        submittedEcp['crush-device-class'] = 'ssd';
         testCreation();
       });
 
@@ -269,35 +332,32 @@ describe('ErasureCodeProfileFormModalComponent', () => {
 
     describe(`'lrc' usage`, () => {
       beforeEach(() => {
-        ecp.name = 'lreProfile';
-        ecp.plugin = 'lrc';
+        ecpChange('name', 'lrcProfile');
+        ecpChange('plugin', 'lrc');
+        submittedEcp.k = 4;
+        submittedEcp.m = 2;
+        submittedEcp.l = 3;
+        delete submittedEcp.packetsize;
+        delete submittedEcp.technique;
       });
 
       it('should be able to create a profile with only required fields', () => {
         formHelper.setMultipleValues(ecp, true);
-        ecp.k = 4;
-        ecp.m = 2;
-        ecp.l = 3;
         testCreation();
       });
 
       it('should send profile with all required fields and crush root and locality', () => {
-        ecp.l = 8;
+        ecpChange('l', '6');
         formHelper.setMultipleValues(ecp, true);
-        ecp.k = 4;
-        ecp.m = 2;
-        formHelper.setValue('crushLocality', 'osd', true);
-        formHelper.setValue('crushRoot', 'rack', true);
-        ecp['crush-locality'] = 'osd';
-        ecp['crush-root'] = 'rack';
+        formHelper.setValue('crushRoot', component.buckets[2], true);
+        submittedEcp['crush-root'] = 'mix-host';
+        formHelper.setValue('crushLocality', 'osd-rack', true);
+        submittedEcp['crush-locality'] = 'osd-rack';
         testCreation();
       });
 
       it('should not send the profile with unsupported fields', () => {
         formHelper.setMultipleValues(ecp, true);
-        ecp.k = 4;
-        ecp.m = 2;
-        ecp.l = 3;
         formHelper.setValue('c', 4, true);
         testCreation();
       });
@@ -305,8 +365,13 @@ describe('ErasureCodeProfileFormModalComponent', () => {
 
     describe(`'shec' usage`, () => {
       beforeEach(() => {
-        ecp.name = 'shecProfile';
-        ecp.plugin = 'shec';
+        ecpChange('name', 'shecProfile');
+        ecpChange('plugin', 'shec');
+        submittedEcp.k = 4;
+        submittedEcp.m = 3;
+        submittedEcp.c = 2;
+        delete submittedEcp.packetsize;
+        delete submittedEcp.technique;
       });
 
       it('should be able to create a profile with only plugin and name', () => {
@@ -315,10 +380,10 @@ describe('ErasureCodeProfileFormModalComponent', () => {
       });
 
       it('should send profile with plugin, name, c and crush device class only', () => {
-        ecp.c = 4;
+        ecpChange('c', '3');
         formHelper.setMultipleValues(ecp, true);
         formHelper.setValue('crushDeviceClass', 'ssd', true);
-        ecp['crush-device-class'] = 'ssd';
+        submittedEcp['crush-device-class'] = 'ssd';
         testCreation();
       });
 
index 6a62a5c87a56364fb55f0a73e8b34a8d40b9e963..aed3493fc4d091aad343f6f4246c5f7c910866bb 100644 (file)
@@ -5,10 +5,12 @@ import { I18n } from '@ngx-translate/i18n-polyfill';
 import { BsModalRef } from 'ngx-bootstrap/modal';
 
 import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
+import { CrushNodeSelectionClass } from '../../../shared/classes/crush.node.selection.class';
 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 { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile';
 import { FinishedTask } from '../../../shared/models/finished-task';
 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
@@ -18,19 +20,12 @@ import { TaskWrapperService } from '../../../shared/services/task-wrapper.servic
   templateUrl: './erasure-code-profile-form-modal.component.html',
   styleUrls: ['./erasure-code-profile-form-modal.component.scss']
 })
-export class ErasureCodeProfileFormModalComponent implements OnInit {
+export class ErasureCodeProfileFormModalComponent extends CrushNodeSelectionClass
+  implements OnInit {
   @Output()
   submitAction = new EventEmitter();
 
-  form: CdFormGroup;
-  failureDomains: string[];
-  plugins: string[];
-  names: string[];
-  techniques: string[];
-  requiredControls: string[] = [];
-  devices: string[] = [];
   tooltips = this.ecpService.formTooltips;
-
   PLUGIN = {
     LRC: 'lrc', // Locally Repairable Erasure Code
     SHEC: 'shec', // Shingled Erasure Code
@@ -38,6 +33,11 @@ export class ErasureCodeProfileFormModalComponent implements OnInit {
     ISA: 'isa' // Intel Storage Acceleration
   };
   plugin = this.PLUGIN.JERASURE;
+
+  form: CdFormGroup;
+  plugins: string[];
+  names: string[];
+  techniques: string[];
   action: string;
   resource: string;
 
@@ -49,6 +49,7 @@ export class ErasureCodeProfileFormModalComponent implements OnInit {
     private i18n: I18n,
     public actionLabels: ActionLabelsI18n
   ) {
+    super();
     this.action = this.actionLabels.CREATE;
     this.resource = this.i18n('EC Profile');
     this.createForm();
@@ -175,27 +176,52 @@ export class ErasureCodeProfileFormModalComponent implements OnInit {
       .getInfo()
       .subscribe(
         ({
-          failure_domains,
           plugins,
           names,
           directory,
-          devices
+          nodes
         }: {
-          failure_domains: string[];
           plugins: string[];
           names: string[];
           directory: string;
-          devices: string[];
+          nodes: CrushNode[];
         }) => {
-          this.failureDomains = failure_domains;
+          this.initCrushNodeSelection(
+            nodes,
+            this.form.get('crushRoot'),
+            this.form.get('crushFailureDomain'),
+            this.form.get('crushDeviceClass')
+          );
           this.plugins = plugins;
           this.names = names;
-          this.devices = devices;
           this.form.silentSet('directory', directory);
         }
       );
   }
 
+  onSubmit() {
+    if (this.form.invalid) {
+      this.form.setErrors({ cdSubmitButton: true });
+      return;
+    }
+    const profile = this.createJson();
+    this.taskWrapper
+      .wrapTaskAroundCall({
+        task: new FinishedTask('ecp/create', { name: profile.name }),
+        call: this.ecpService.create(profile)
+      })
+      .subscribe(
+        undefined,
+        () => {
+          this.form.setErrors({ cdSubmitButton: true });
+        },
+        () => {
+          this.bsModalRef.hide();
+          this.submitAction.emit(profile);
+        }
+      );
+  }
+
   private createJson() {
     const pluginControls = {
       technique: [this.PLUGIN.ISA, this.PLUGIN.JERASURE],
@@ -209,13 +235,9 @@ export class ErasureCodeProfileFormModalComponent implements OnInit {
     Object.keys(this.form.controls)
       .filter((name) => {
         const pluginControl = pluginControls[name];
-        const control = this.form.get(name);
+        const value = this.form.getValue(name);
         const usable = (pluginControl && pluginControl.includes(plugin)) || !pluginControl;
-        return (
-          usable &&
-          (control.dirty || this.requiredControls.includes(name)) &&
-          this.form.getValue(name)
-        );
+        return usable && value && value !== '';
       })
       .forEach((name) => {
         this.extendJson(name, ecp);
@@ -231,29 +253,7 @@ export class ErasureCodeProfileFormModalComponent implements OnInit {
       packetSize: 'packetsize',
       crushLocality: 'crush-locality'
     };
-    ecp[differentApiAttributes[name] || name] = this.form.getValue(name);
-  }
-
-  onSubmit() {
-    if (this.form.invalid) {
-      this.form.setErrors({ cdSubmitButton: true });
-      return;
-    }
-    const profile = this.createJson();
-    this.taskWrapper
-      .wrapTaskAroundCall({
-        task: new FinishedTask('ecp/create', { name: profile.name }),
-        call: this.ecpService.create(profile)
-      })
-      .subscribe(
-        undefined,
-        () => {
-          this.form.setErrors({ cdSubmitButton: true });
-        },
-        () => {
-          this.bsModalRef.hide();
-          this.submitAction.emit(profile);
-        }
-      );
+    const value = this.form.getValue(name);
+    ecp[differentApiAttributes[name] || name] = name === 'crushRoot' ? value.name : value;
   }
 }
index bbfd237eb0f86a50dc66078d1762c4f1d85d2f90..aeb5d438184358a8cf6ef0a80e4fd9545a448330 100644 (file)
                   <button class="btn btn-light"
                           type="button"
                           *ngIf="!editing"
+                          tooltip="This profile can't be deleted as it is in use."
+                          i18n-tooltip
+                          triggers=""
+                          #ecpDeletionBtn="bs-tooltip"
                           (click)="deleteErasureCodeProfile()">
                     <i [ngClass]="[icons.trash]"
                        aria-hidden="true"></i>
               <span class="form-text text-muted"
                     id="ecp-info-block"
                     *ngIf="data.erasureInfo && form.getValue('erasureProfile')">
-                <cd-table-key-value [renderObjects]="true"
-                                    [data]="form.getValue('erasureProfile')"
-                                    [autoReload]="false">
-                </cd-table-key-value>
+                <tabset #ecpInfoTabs>
+                  <tab i18n-heading
+                       heading="Profile"
+                       class="ecp-info">
+                    <cd-table-key-value [renderObjects]="true"
+                                        [data]="form.getValue('erasureProfile')"
+                                        [autoReload]="false">
+                    </cd-table-key-value>
+                  </tab>
+                  <tab i18n-heading
+                       heading="Used by pools"
+                       class="used-by-pools">
+                    <ng-template #ecpIsNotUsed>
+                      <span i18n>Profile is not in use.</span>
+                    </ng-template>
+                    <ul *ngIf="ecpUsage; else ecpIsNotUsed">
+                      <li *ngFor="let pool of ecpUsage">
+                        {{ pool }}
+                      </li>
+                    </ul>
+                  </tab>
+                </tabset>
               </span>
             </div>
           </div>
index 5f06047c1a46b0f0baa3fabd6e120773940bfb1a..c4ee052ae1f555d9c9fd7cc2cb0083b253e5da81 100644 (file)
@@ -7,16 +7,18 @@ import { RouterTestingModule } from '@angular/router/testing';
 
 import * as _ from 'lodash';
 import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation';
-import { BsModalService } from 'ngx-bootstrap/modal';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
 import { TabsetComponent, TabsModule } from 'ngx-bootstrap/tabs';
 import { ToastrModule } from 'ngx-toastr';
 import { of } from 'rxjs';
 
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import {
   configureTestBed,
   FixtureHelper,
   FormHelper,
-  i18nProviders
+  i18nProviders,
+  modalServiceShow
 } from '../../../../testing/unit-test-helper';
 import { NotFoundComponent } from '../../../core/not-found/not-found.component';
 import { CrushRuleService } from '../../../shared/api/crush-rule.service';
@@ -146,6 +148,9 @@ describe('PoolFormComponent', () => {
       pg_autoscale_modes: ['off', 'warn', 'on'],
       used_rules: {
         used_rule: ['some.pool.uses.it']
+      },
+      used_profiles: {
+        ecp1: ['some.other.pool.uses.it']
       }
     };
   };
@@ -165,6 +170,7 @@ describe('PoolFormComponent', () => {
   configureTestBed({
     declarations: [NotFoundComponent],
     imports: [
+      BrowserAnimationsModule,
       HttpClientTestingModule,
       RouterTestingModule.withRoutes(routes),
       ToastrModule.forRoot(),
@@ -174,6 +180,7 @@ describe('PoolFormComponent', () => {
     ],
     providers: [
       ErasureCodeProfileService,
+      BsModalRef,
       SelectBadgesComponent,
       { provide: ActivatedRoute, useValue: { params: of({ name: 'somePoolName' }) } },
       i18nProviders
@@ -973,45 +980,145 @@ describe('PoolFormComponent', () => {
       fixtureHelper.expectIdElementsVisible(['erasureProfile', 'ecp-info-block'], true);
     });
 
+    it('should select the newly created profile', () => {
+      spyOn(ecpService, 'list').and.callFake(() => of(infoReturn.erasure_code_profiles));
+      expect(form.getValue('erasureProfile').name).toBe('ecp1');
+      const name = 'awesomeProfile';
+      spyOn(TestBed.get(BsModalService), 'show').and.callFake(() => {
+        return {
+          content: {
+            submitAction: of({ name })
+          }
+        };
+      });
+      const ecp2 = new ErasureCodeProfile();
+      ecp2.name = name;
+      infoReturn.erasure_code_profiles.push(ecp2);
+      component.addErasureCodeProfile();
+      expect(form.getValue('erasureProfile').name).toBe(name);
+    });
+
     describe('ecp deletion', () => {
       let taskWrapper: TaskWrapperService;
       let deletion: CriticalConfirmationModalComponent;
+      let deleteSpy: jasmine.Spy;
+      let modalSpy: jasmine.Spy;
+      let modal: any;
 
-      const callDeletion = () => {
+      const callEcpDeletion = () => {
         component.deleteErasureCodeProfile();
-        deletion.submitActionObservable();
+        modal.ref.content.callSubmitAction();
       };
 
-      const testPoolDeletion = (name: string) => {
+      const expectSuccessfulEcpDeletion = (name: string) => {
         setSelectedEcp(name);
-        callDeletion();
+        callEcpDeletion();
         expect(ecpService.delete).toHaveBeenCalledWith(name);
-        expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({
-          task: {
-            name: 'ecp/delete',
-            metadata: {
-              name: name
+        expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith(
+          expect.objectContaining({
+            task: {
+              name: 'ecp/delete',
+              metadata: {
+                name: name
+              }
             }
-          },
-          call: undefined // because of stub
-        });
+          })
+        );
       };
 
       beforeEach(() => {
-        spyOn(TestBed.get(BsModalService), 'show').and.callFake((deletionClass, config) => {
-          deletion = Object.assign(new deletionClass(), config.initialState);
-          return {
-            content: deletion
-          };
+        deletion = undefined;
+        modalSpy = spyOn(TestBed.get(BsModalService), 'show').and.callFake(
+          (comp: any, init: any) => {
+            modal = modalServiceShow(comp, init);
+            return modal.ref;
+          }
+        );
+        deleteSpy = spyOn(ecpService, 'delete').and.callFake((name: string) => {
+          const profiles = infoReturn.erasure_code_profiles;
+          const index = _.findIndex(profiles, (profile) => profile.name === name);
+          profiles.splice(index, 1);
+          return of({ status: 202 });
         });
-        spyOn(ecpService, 'delete').and.stub();
         taskWrapper = TestBed.get(TaskWrapperService);
         spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+
+        const ecp2 = new ErasureCodeProfile();
+        ecp2.name = 'someEcpName';
+        infoReturn.erasure_code_profiles.push(ecp2);
+
+        const ecp3 = new ErasureCodeProfile();
+        ecp3.name = 'aDifferentEcpName';
+        infoReturn.erasure_code_profiles.push(ecp3);
       });
 
       it('should delete two different erasure code profiles', () => {
-        testPoolDeletion('someEcpName');
-        testPoolDeletion('aDifferentEcpName');
+        expectSuccessfulEcpDeletion('someEcpName');
+        expectSuccessfulEcpDeletion('aDifferentEcpName');
+      });
+
+      describe('with unused profile', () => {
+        beforeEach(() => {
+          expectSuccessfulEcpDeletion('someEcpName');
+        });
+
+        it('should not open the tooltip nor the crush info', () => {
+          expect(component.ecpDeletionBtn.isOpen).toBe(false);
+          expect(component.data.erasureInfo).toBe(false);
+        });
+
+        it('should reload the rules after deletion', () => {
+          const expected = infoReturn.erasure_code_profiles;
+          const currentProfiles = component.info.erasure_code_profiles;
+          expect(currentProfiles.length).toBe(expected.length);
+          expect(currentProfiles).toEqual(expected);
+        });
+      });
+
+      describe('rule in use', () => {
+        beforeEach(() => {
+          spyOn(global, 'setTimeout').and.callFake((fn: Function) => fn());
+          component.ecpInfoTabs = { tabs: [{}, {}] } as TabsetComponent; // Mock it
+          deleteSpy.calls.reset();
+          setSelectedEcp('ecp1');
+          component.deleteErasureCodeProfile();
+        });
+
+        it('should not open the modal', () => {
+          expect(deletion).toBe(undefined);
+        });
+
+        it('should not have called delete and opened the tooltip', () => {
+          expect(ecpService.delete).not.toHaveBeenCalled();
+          expect(component.ecpDeletionBtn.isOpen).toBe(true);
+          expect(component.data.erasureInfo).toBe(true);
+        });
+
+        it('should open the third crush info tab', () => {
+          expect(component.ecpInfoTabs).toEqual({
+            tabs: [{}, { active: true }]
+          } as TabsetComponent);
+        });
+
+        it('should hide the tooltip when clicking on delete again', () => {
+          component.deleteErasureCodeProfile();
+          expect(component.ecpDeletionBtn.isOpen).toBe(false);
+        });
+
+        it('should hide the tooltip when clicking on add', () => {
+          modalSpy.and.callFake((): any => ({
+            content: {
+              submitAction: of('someProfile')
+            }
+          }));
+          component.addErasureCodeProfile();
+          expect(component.ecpDeletionBtn.isOpen).toBe(false);
+        });
+
+        it('should hide the tooltip when changing the crush rule', () => {
+          setSelectedEcp('someEcpName');
+          expect(component.ecpDeletionBtn.isOpen).toBe(false);
+        });
       });
     });
   });
index 7c1b409155ea5779a003250a1777f645a66fe909..47519c0a0a34007aab3c4d81e6f6844676337631 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, EventEmitter, OnInit, ViewChild } from '@angular/core';
+import { Component, EventEmitter, OnInit, Type, ViewChild } from '@angular/core';
 import { FormControl, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 
@@ -7,7 +7,7 @@ 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 { Observable, Subscription } from 'rxjs';
 
 import { CrushRuleService } from '../../../shared/api/crush-rule.service';
 import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
@@ -22,7 +22,7 @@ import {
   RbdConfigurationEntry,
   RbdConfigurationSourceField
 } from '../../../shared/models/configuration';
-import { CrushRule, CrushRuleConfig } from '../../../shared/models/crush-rule';
+import { CrushRule } 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';
@@ -54,6 +54,8 @@ interface FormFieldDescription {
 export class PoolFormComponent implements OnInit {
   @ViewChild('crushInfoTabs', { static: false }) crushInfoTabs: TabsetComponent;
   @ViewChild('crushDeletionBtn', { static: false }) crushDeletionBtn: TooltipDirective;
+  @ViewChild('ecpInfoTabs', { static: false }) ecpInfoTabs: TabsetComponent;
+  @ViewChild('ecpDeletionBtn', { static: false }) ecpDeletionBtn: TooltipDirective;
 
   permission: Permission;
   form: CdFormGroup;
@@ -78,6 +80,7 @@ export class PoolFormComponent implements OnInit {
   icons = Icons;
   pgAutoscaleModes: string[];
   crushUsage: string[] = undefined; // Will only be set if a rule is used by some pool
+  ecpUsage: string[] = undefined; // Will only be set if a rule is used by some pool
 
   private modalSubscription: Subscription;
 
@@ -341,8 +344,15 @@ export class PoolFormComponent implements OnInit {
       // The size can only be changed if type 'replicated' is set.
       this.pgCalc();
     });
-    this.form.get('erasureProfile').valueChanges.subscribe(() => {
+    this.form.get('erasureProfile').valueChanges.subscribe((profile) => {
       // The ec profile can only be changed if type 'erasure' is set.
+      if (this.ecpDeletionBtn && this.ecpDeletionBtn.isOpen) {
+        this.ecpDeletionBtn.hide();
+      }
+      if (!profile) {
+        return;
+      }
+      this.ecpIsUsedBy(profile.name);
       this.pgCalc();
     });
     this.form.get('mode').valueChanges.subscribe(() => {
@@ -542,96 +552,175 @@ export class PoolFormComponent implements OnInit {
   }
 
   addErasureCodeProfile() {
-    this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadECPs());
-    this.bsModalService.show(ErasureCodeProfileFormModalComponent);
+    this.addModal(ErasureCodeProfileFormModalComponent, (name) => this.reloadECPs(name));
   }
 
-  private reloadECPs() {
-    this.ecpService.list().subscribe((profiles: ErasureCodeProfile[]) => this.initEcp(profiles));
-    this.modalSubscription.unsubscribe();
+  private addModal(modalComponent: Type<any>, reload: (name: string) => void) {
+    this.hideOpenTooltips();
+    const modalRef = this.bsModalService.show(modalComponent);
+    modalRef.content.submitAction.subscribe((item: any) => {
+      reload(item.name);
+    });
   }
 
-  deleteErasureCodeProfile() {
-    const ecp = this.form.getValue('erasureProfile');
-    if (!ecp) {
-      return;
-    }
-    const name = ecp.name;
-    this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadECPs());
-    this.modalService.show(CriticalConfirmationModalComponent, {
-      initialState: {
-        itemDescription: this.i18n('erasure code profile'),
-        itemNames: [name],
-        submitActionObservable: () =>
-          this.taskWrapper.wrapTaskAroundCall({
-            task: new FinishedTask('ecp/delete', { name: name }),
-            call: this.ecpService.delete(name)
-          })
-      }
-    });
+  private hideOpenTooltips() {
+    const hideTooltip = (btn: TooltipDirective) => btn && btn.isOpen && btn.hide();
+    hideTooltip(this.ecpDeletionBtn);
+    hideTooltip(this.crushDeletionBtn);
   }
 
-  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 reloadECPs(profileName?: string) {
+    this.reloadList({
+      newItemName: profileName,
+      getInfo: () => this.ecpService.list(),
+      initInfo: (profiles) => this.initEcp(profiles),
+      findNewItem: () => this.ecProfiles.find((p) => p.name === profileName),
+      controlName: 'erasureProfile'
     });
   }
 
-  private reloadCrushRules(ruleName?: string) {
+  private reloadList({
+    newItemName,
+    getInfo,
+    initInfo,
+    findNewItem,
+    controlName
+  }: {
+    newItemName: string;
+    getInfo: () => Observable<any>;
+    initInfo: (items: any) => void;
+    findNewItem: () => any;
+    controlName: string;
+  }) {
     if (this.modalSubscription) {
       this.modalSubscription.unsubscribe();
     }
-    this.poolService.getInfo().subscribe((info: PoolFormInfo) => {
-      this.initInfo(info);
-      this.poolTypeChange('replicated');
-      if (!ruleName) {
+    getInfo().subscribe((items: any) => {
+      initInfo(items);
+      if (!newItemName) {
         return;
       }
-      const newRule = this.info.crush_rules_replicated.find((rule) => rule.rule_name === ruleName);
-      if (newRule) {
-        this.form.get('crushRule').setValue(newRule);
+      const item = findNewItem();
+      if (item) {
+        this.form.get(controlName).setValue(item);
       }
     });
   }
 
-  deleteCrushRule() {
-    const rule = this.form.getValue('crushRule');
-    if (!rule) {
+  deleteErasureCodeProfile() {
+    this.deletionModal({
+      value: this.form.getValue('erasureProfile'),
+      usage: this.ecpUsage,
+      deletionBtn: this.ecpDeletionBtn,
+      dataName: 'erasureInfo',
+      getTabs: () => this.ecpInfoTabs,
+      tabPosition: 1,
+      nameAttribute: 'name',
+      itemDescription: this.i18n('erasure code profile'),
+      reloadFn: () => this.reloadECPs(),
+      deleteFn: (name) => this.ecpService.delete(name),
+      taskName: 'ecp/delete'
+    });
+  }
+
+  private deletionModal({
+    value,
+    usage,
+    deletionBtn,
+    dataName,
+    getTabs,
+    tabPosition,
+    nameAttribute,
+    itemDescription,
+    reloadFn,
+    deleteFn,
+    taskName
+  }: {
+    value: any;
+    usage: string[];
+    deletionBtn: TooltipDirective;
+    dataName: string;
+    getTabs: () => TabsetComponent;
+    tabPosition: number;
+    nameAttribute: string;
+    itemDescription: string;
+    reloadFn: Function;
+    deleteFn: (name: string) => Observable<any>;
+    taskName: string;
+  }) {
+    if (!value) {
       return;
     }
-    if (this.crushUsage) {
-      this.crushDeletionBtn.toggle();
-      this.data.crushInfo = true;
+    if (usage) {
+      deletionBtn.toggle();
+      this.data[dataName] = true;
       setTimeout(() => {
-        if (this.crushInfoTabs) {
-          this.crushInfoTabs.tabs[2].active = true;
+        const tabs = getTabs();
+        if (tabs) {
+          tabs.tabs[tabPosition].active = true;
         }
       }, 50);
       return;
     }
-    const name = rule.rule_name;
-    this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadCrushRules());
+    const name = value[nameAttribute];
     this.modalService.show(CriticalConfirmationModalComponent, {
       initialState: {
-        itemDescription: this.i18n('crush rule'),
+        itemDescription,
         itemNames: [name],
-        submitActionObservable: () =>
-          this.taskWrapper.wrapTaskAroundCall({
-            task: new FinishedTask('crushRule/delete', { name: name }),
-            call: this.crushRuleService.delete(name)
-          })
+        submitActionObservable: () => {
+          const deletion = deleteFn(name);
+          deletion.subscribe(() => reloadFn());
+          return this.taskWrapper.wrapTaskAroundCall({
+            task: new FinishedTask(taskName, { name: name }),
+            call: deletion
+          });
+        }
       }
     });
   }
 
+  addCrushRule() {
+    this.addModal(CrushRuleFormModalComponent, (name) => this.reloadCrushRules(name));
+  }
+
+  private reloadCrushRules(ruleName?: string) {
+    this.reloadList({
+      newItemName: ruleName,
+      getInfo: () => this.poolService.getInfo(),
+      initInfo: (info) => {
+        this.initInfo(info);
+        this.poolTypeChange('replicated');
+      },
+      findNewItem: () =>
+        this.info.crush_rules_replicated.find((rule) => rule.rule_name === ruleName),
+      controlName: 'crushRule'
+    });
+  }
+
+  deleteCrushRule() {
+    this.deletionModal({
+      value: this.form.getValue('crushRule'),
+      usage: this.crushUsage,
+      deletionBtn: this.crushDeletionBtn,
+      dataName: 'crushInfo',
+      getTabs: () => this.crushInfoTabs,
+      tabPosition: 2,
+      nameAttribute: 'rule_name',
+      itemDescription: this.i18n('crush rule'),
+      reloadFn: () => this.reloadCrushRules(),
+      deleteFn: (name) => this.crushRuleService.delete(name),
+      taskName: 'crushRule/delete'
+    });
+  }
+
   crushRuleIsUsedBy(ruleName: string) {
     this.crushUsage = ruleName ? this.info.used_rules[ruleName] : undefined;
   }
 
+  ecpIsUsedBy(profileName: string) {
+    this.ecpUsage = profileName ? this.info.used_profiles[profileName] : undefined;
+  }
+
   submit() {
     if (this.form.invalid) {
       this.form.setErrors({ cdSubmitButton: true });
index 16fa24ca9c7d2dba440f51c04576ee13468c8feb..4eb09e67c3c1297cd8e9030e4508e5486a05bc64 100644 (file)
@@ -14,4 +14,5 @@ export class PoolFormInfo {
   pg_autoscale_modes: string[];
   erasure_code_profiles: ErasureCodeProfile[];
   used_rules: { [rule_name: string]: string[] };
+  used_profiles: { [profile_name: string]: string[] };
 }