]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: Adds ECP management to the frontend
authorStephan Müller <smueller@suse.com>
Wed, 1 Aug 2018 09:36:41 +0000 (11:36 +0200)
committerStephan Müller <smueller@suse.com>
Fri, 9 Nov 2018 08:40:39 +0000 (09:40 +0100)
Now you can create, delete and get information about profiles inside
the pool form.

The erasure code profile form has a lot of tooltips to guide you through
the creation. It can create profiles with different plugins.

Fixes: https://tracker.ceph.com/issues/25156
Signed-off-by: Stephan Müller <smueller@suse.com>
15 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-tooltips.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.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/ceph/pool/pool.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-tooltips.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-tooltips.ts
new file mode 100644 (file)
index 0000000..c74a6bd
--- /dev/null
@@ -0,0 +1,63 @@
+export class ErasureCodeProfileFormTooltips {
+  // Copied from /srv/cephmgr/ceph-dev/doc/rados/operations/erasure-code.*.rst
+  k = `Each object is split in data-chunks parts, each stored on a different OSD.`;
+
+  m = `Compute coding chunks for each object and store them on different OSDs.
+    The number of coding chunks is also the number of OSDs that can be down without losing data.`;
+
+  plugins = {
+    jerasure: {
+      description: `The jerasure plugin is the most generic and flexible plugin,
+        it is also the default for Ceph erasure coded pools.`,
+      technique: `The more flexible technique is reed_sol_van : it is enough to set k and m.
+        The cauchy_good technique can be faster but you need to chose the packetsize carefully.
+        All of reed_sol_r6_op, liberation, blaum_roth, liber8tion are RAID6 equivalents in the
+        sense that they can only be configured with m=2.`,
+      packetSize: `The encoding will be done on packets of bytes size at a time.
+        Chosing the right packet size is difficult.
+        The jerasure documentation contains extensive information on this topic.`
+    },
+    lrc: {
+      description: `With the jerasure plugin, when an erasure coded object is stored
+        on multiple OSDs, recovering from the loss of one OSD requires reading from all the others.
+        For instance if jerasure is configured with k=8 and m=4, losing one OSD requires
+        reading from the eleven others to repair.
+
+        The lrc erasure code plugin creates local parity chunks to be able to recover using
+        less OSDs. For instance if lrc is configured with k=8, m=4 and l=4, it will create
+        an additional parity chunk for every four OSDs. When a single OSD is lost, it can be
+        recovered with only four OSDs instead of eleven.`,
+      l: `Group the coding and data chunks into sets of size locality. For instance,
+        for k=4 and m=2, when locality=3 two groups of three are created. Each set can
+        be recovered without reading chunks from another set.`,
+      crushLocality: `The type of the crush bucket in which each set of chunks defined by l
+        will be stored. For instance, if it is set to rack, each group of l chunks will be placed
+        in a different rack. It is used to create a CRUSH rule step such as step choose rack.
+        If it is not set, no such grouping is done.`
+    },
+    isa: {
+      description: `The isa plugin encapsulates the ISA library. It only runs on Intel processors.`,
+      technique: `The ISA plugin comes in two Reed Solomon forms.
+        If reed_sol_van is set, it is Vandermonde, if cauchy is set, it is Cauchy.`
+    },
+    shec: {
+      description: `The shec plugin encapsulates the multiple SHEC library.
+        It allows ceph to recover data more efficiently than Reed Solomon codes.`,
+      c: `The number of parity chunks each of which includes each data chunk in its calculation
+        range. The number is used as a durability estimator. For instance, if c=2, 2 OSDs can
+        be down without losing data.`
+    }
+  };
+
+  crushRoot = `The name of the crush bucket used for the first step of the CRUSH rule.
+    For instance step take default.`;
+
+  crushFailureDomain = `Ensure that no two chunks are in a bucket with the same failure domain.
+    For instance, if the failure domain is host no two chunks will be stored on the same host.
+    It is used to create a CRUSH rule step such as step chooseleaf host.`;
+
+  crushDeviceClass = `Restrict placement to devices of a specific class (e.g., ssd or hdd),
+    using the crush device class names in the CRUSH map.`;
+
+  directory = `Set the directory name from which the erasure code plugin is loaded.`;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html
new file mode 100644 (file)
index 0000000..43be426
--- /dev/null
@@ -0,0 +1,394 @@
+<div class="modal-header">
+  <h4 class="modal-title pull-left"
+      i18n>Add erasure code profile
+  </h4>
+  <button type="button"
+          class="close pull-right"
+          aria-label="Close"
+          (click)="bsModalRef.hide()">
+    <span aria-hidden="true">&times;</span>
+  </button>
+</div>
+
+<form class="form-horizontal"
+      #frm="ngForm"
+      [formGroup]="form"
+      novalidate>
+  <div class="modal-body">
+    <div class="form-group"
+         [ngClass]="{'has-error': form.showError('name', frm)}">
+      <label i18n
+             for="name"
+             class="control-label col-sm-3">
+        Name
+        <span class="required"></span>
+      </label>
+      <div class="col-sm-9">
+        <input type="text"
+               id="name"
+               name="name"
+               class="form-control"
+               placeholder="Name..."
+               formControlName="name"
+               autofocus>
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('name', frm, 'required')">
+          This field is required!
+        </span>
+        <span class="help-block"
+              *ngIf="form.showError('name', frm, 'pattern')">
+          The name can only consist of alphanumeric characters, dashes and underscores.
+        </span>
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('name', frm, 'uniqueName')">
+          The chosen erasure code profile name is already in use.
+        </span>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label i18n
+             for="plugin"
+             class="control-label col-sm-3">
+        Plugin
+        <span class="required"></span>
+        <cd-helper i18n-html
+                   [html]="tooltips.plugins[plugin].description">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <select class="form-control"
+                id="plugin"
+                name="plugin"
+                formControlName="plugin">
+          <option *ngIf="!plugins"
+                  ngValue=""
+                  i18n>
+            Loading...
+          </option>
+          <option *ngFor="let plugin of plugins"
+                  [ngValue]="plugin">
+            {{ plugin }}
+          </option>
+        </select>
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('name', frm, 'required')">
+          This field is required!
+        </span>
+      </div>
+    </div>
+
+    <div class="form-group"
+         [ngClass]="{'has-error': form.showError('k', frm)}">
+      <label i18n
+             for="k"
+             class="control-label col-sm-3">
+        Data chunks (k)
+        <span class="required"
+              *ngIf="requiredControls.includes('k')"></span>
+        <cd-helper i18n-html
+                   [html]="tooltips.k">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <input type="number"
+               id="k"
+               name="k"
+               class="form-control"
+               ng-model="$ctrl.erasureCodeProfile.k"
+               placeholder="Data chunks..."
+               formControlName="k">
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('k', frm, 'required')">
+          This field is required!
+        </span>
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('k', frm, 'min')">
+          Must be equal to or greater than 2.
+        </span>
+      </div>
+    </div>
+
+    <div class="form-group"
+         [ngClass]="{'has-error': form.showError('m', frm)}">
+      <label i18n
+             for="m"
+             class="control-label col-sm-3">
+        Coding chunks (m)
+        <span class="required"
+              *ngIf="requiredControls.includes('m')"></span>
+        <cd-helper i18n-html
+                   [html]="tooltips.m">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <input type="number"
+               id="m"
+               name="m"
+               class="form-control"
+               placeholder="Coding chunks..."
+               formControlName="m">
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('m', frm, 'required')">
+          This field is required!
+        </span>
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('m', frm, 'min')">
+          Must be equal to or greater than 1.
+        </span>
+      </div>
+    </div>
+
+    <div class="form-group"
+         *ngIf="plugin === 'shec'"
+         [ngClass]="{'has-error': form.showError('c', frm)}">
+      <label i18n
+             for="c"
+             class="control-label col-sm-3">
+        Durability estimator (c)
+        <cd-helper i18n-html
+                   [html]="tooltips.plugins.shec.c">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <input type="number"
+               id="c"
+               name="c"
+               class="form-control"
+               placeholder="Coding chunks..."
+               formControlName="c">
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('c', frm, 'min')">
+          Must be equal to or greater than 1.
+        </span>
+      </div>
+    </div>
+
+    <div class="form-group"
+         *ngIf="plugin === PLUGIN.LRC"
+         [ngClass]="{'has-error': form.showError('l', frm)}">
+      <label i18n
+             for="l"
+             class="control-label col-sm-3">
+        Locality (l)
+        <span class="required"></span>
+        <cd-helper i18n-html
+                   [html]="tooltips.plugins.lrc.l">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <input type="number"
+               id="l"
+               name="l"
+               class="form-control"
+               placeholder="Coding chunks..."
+               formControlName="l">
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('l', frm, 'required')">
+          This field is required!
+        </span>
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('l', frm, 'min')">
+          Must be equal to or greater than 1.
+        </span>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label i18n
+             for="crushFailureDomain"
+             class="control-label col-sm-3">
+        Crush failure domain
+        <cd-helper i18n-html
+                   [html]="tooltips.crushFailureDomain">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <select class="form-control"
+                id="crushFailureDomain"
+                name="crushFailureDomain"
+                formControlName="crushFailureDomain">
+          <option *ngIf="!failureDomains"
+                  ngValue=""
+                  i18n>
+            Loading...
+          </option>
+          <option *ngFor="let domain of failureDomains"
+                  [ngValue]="domain">
+            {{ domain }}
+          </option>
+        </select>
+      </div>
+    </div>
+
+    <div class="form-group"
+         *ngIf="plugin === PLUGIN.LRC">
+      <label i18n
+             for="crushLocality"
+             class="control-label col-sm-3">
+        Crush Locality
+        <cd-helper i18n-html
+                   [html]="tooltips.plugins.lrc.crushLocality">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <select class="form-control"
+                id="crushLocality"
+                name="crushLocality"
+                formControlName="crushLocality">
+          <option *ngIf="!failureDomains"
+                  ngValue=""
+                  i18n>
+            Loading...
+          </option>
+          <option *ngIf="failureDomains && failureDomains.length > 0"
+                  ngValue=""
+                  i18n>
+            None
+          </option>
+          <option *ngFor="let domain of failureDomains"
+                  [ngValue]="domain">
+            {{ domain }}
+          </option>
+        </select>
+      </div>
+    </div>
+
+    <div class="form-group"
+         *ngIf="[PLUGIN.JERASURE, PLUGIN.ISA].includes(plugin)">
+      <label i18n
+             for="technique"
+             class="control-label col-sm-3">
+        Technique
+        <cd-helper i18n-html
+                   [html]="tooltips.plugins[plugin].technique">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <select class="form-control"
+                id="technique"
+                name="technique"
+                formControlName="technique">
+          <option *ngFor="let technique of techniques"
+                  [ngValue]="technique">
+            {{ technique }}
+          </option>
+        </select>
+      </div>
+    </div>
+
+    <div class="form-group"
+         *ngIf="plugin === PLUGIN.JERASURE"
+         [ngClass]="{'has-error': form.showError('packetSize', frm)}">
+      <label i18n
+             for="packetSize"
+             class="control-label col-sm-3">
+        Packetsize
+        <cd-helper i18n-html
+                   [html]="tooltips.plugins.jerasure.packetSize">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <input type="number"
+               id="packetSize"
+               name="packetSize"
+               class="form-control"
+               placeholder="Packetsize..."
+               formControlName="packetSize">
+        <span i18n
+              class="help-block"
+              *ngIf="form.showError('packetSize', frm, 'min')">
+          Must be equal to or greater than 1.
+        </span>
+      </div>
+    </div>
+
+    <div class="form-group"
+         [ngClass]="{'has-error': form.showError('crushRoot', frm)}">
+      <label i18n
+             for="crushRoot"
+             class="control-label col-sm-3">
+        Crush root
+        <cd-helper i18n-html
+                   [html]="tooltips.crushRoot">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <input type="text"
+               id="crushRoot"
+               name="crushRoot"
+               class="form-control"
+               placeholder="root..."
+               formControlName="crushRoot">
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label i18n
+             for="crushDeviceClass"
+             class="control-label col-sm-3">
+        Crush device class
+        <cd-helper i18n-html
+                   [html]="tooltips.crushDeviceClass">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <select class="form-control"
+                id="crushDeviceClass"
+                name="crushDeviceClass"
+                formControlName="crushDeviceClass">
+          <option ngValue=""
+                  i18n>
+            any
+          </option>
+          <option *ngFor="let deviceClass of devices"
+                  [ngValue]="deviceClass">
+            {{ deviceClass }}
+          </option>
+        </select>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label i18n
+             for="directory"
+             class="control-label col-sm-3">
+        Directory
+        <cd-helper i18n-html
+                   [html]="tooltips.directory">
+        </cd-helper>
+      </label>
+      <div class="col-sm-9">
+        <input type="text"
+               id="directory"
+               name="directory"
+               class="form-control"
+               placeholder="Path..."
+               formControlName="directory">
+      </div>
+    </div>
+  </div>
+
+  <div class="modal-footer">
+    <cd-submit-button (submitAction)="onSubmit()"
+                      [form]="frm"
+                      i18n>
+      Add
+    </cd-submit-button>
+    <button class="btn btn-sm btn-default"
+            type="button"
+            (click)="bsModalRef.hide()"
+            i18n>Close
+    </button>
+  </div>
+</form>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.spec.ts
new file mode 100644 (file)
index 0000000..b5819e9
--- /dev/null
@@ -0,0 +1,324 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import { BsModalRef } from 'ngx-bootstrap/modal';
+import { of } from 'rxjs';
+
+import { configureTestBed, FormHelper } from '../../../../testing/unit-test-helper';
+import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
+import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { PoolModule } from '../pool.module';
+import { ErasureCodeProfileFormComponent } from './erasure-code-profile-form.component';
+
+describe('ErasureCodeProfileFormComponent', () => {
+  let component: ErasureCodeProfileFormComponent;
+  let ecpService: ErasureCodeProfileService;
+  let fixture: ComponentFixture<ErasureCodeProfileFormComponent>;
+  let formHelper: FormHelper;
+  let data: {};
+
+  configureTestBed({
+    imports: [HttpClientTestingModule, RouterTestingModule, ToastModule.forRoot(), PoolModule],
+    providers: [ErasureCodeProfileService, BsModalRef]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(ErasureCodeProfileFormComponent);
+    component = fixture.componentInstance;
+    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']
+    };
+    spyOn(ecpService, 'getInfo').and.callFake(() => of(data));
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('calls listing to get ecps on ngInit', () => {
+    expect(ecpService.getInfo).toHaveBeenCalled();
+    expect(component.names.length).toBe(2);
+  });
+
+  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('sets k to min error', () => {
+      formHelper.expectErrorChange('k', 0, 'min');
+    });
+
+    it('sets m to min error', () => {
+      formHelper.expectErrorChange('m', 0, 'min');
+    });
+
+    it(`should show all default form controls`, () => {
+      const showDefaults = (plugin) => {
+        formHelper.setValue('plugin', plugin);
+        formHelper.expectIdElementsVisible(
+          fixture,
+          [
+            'name',
+            'plugin',
+            'k',
+            'm',
+            'crushFailureDomain',
+            'crushRoot',
+            'crushDeviceClass',
+            'directory'
+          ],
+          true
+        );
+      };
+      showDefaults('jerasure');
+      showDefaults('shec');
+      showDefaults('lrc');
+      showDefaults('isa');
+    });
+
+    describe(`for 'jerasure' plugin (default)`, () => {
+      it(`requires 'm' and 'k'`, () => {
+        formHelper.expectErrorChange('k', null, 'required');
+        formHelper.expectErrorChange('m', null, 'required');
+      });
+
+      it(`should show 'packetSize' and 'technique'`, () => {
+        formHelper.expectIdElementsVisible(fixture, ['packetSize', 'technique'], true);
+      });
+
+      it(`should not show any other plugin specific form control`, () => {
+        formHelper.expectIdElementsVisible(fixture, ['c', 'l', 'crushLocality'], false);
+      });
+    });
+
+    describe(`for 'isa' plugin`, () => {
+      beforeEach(() => {
+        formHelper.setValue('plugin', 'isa');
+      });
+
+      it(`does not require 'm' and 'k'`, () => {
+        formHelper.setValue('k', null);
+        formHelper.expectValidChange('k', null);
+        formHelper.expectValidChange('m', null);
+      });
+
+      it(`should show 'technique'`, () => {
+        formHelper.expectIdElementsVisible(fixture, ['technique'], true);
+        expect(fixture.debugElement.query(By.css('#technique'))).toBeTruthy();
+      });
+
+      it(`should not show any other plugin specific form control`, () => {
+        formHelper.expectIdElementsVisible(
+          fixture,
+          ['c', 'l', 'crushLocality', 'packetSize'],
+          false
+        );
+      });
+    });
+
+    describe(`for 'lrc' plugin`, () => {
+      beforeEach(() => {
+        formHelper.setValue('plugin', 'lrc');
+      });
+
+      it(`requires 'm', 'l' and 'k'`, () => {
+        formHelper.expectErrorChange('k', null, 'required');
+        formHelper.expectErrorChange('m', null, 'required');
+      });
+
+      it(`should show 'l' and 'crushLocality'`, () => {
+        formHelper.expectIdElementsVisible(fixture, ['l', 'crushLocality'], true);
+      });
+
+      it(`should not show any other plugin specific form control`, () => {
+        formHelper.expectIdElementsVisible(fixture, ['c', 'packetSize', 'technique'], false);
+      });
+    });
+
+    describe(`for 'shec' plugin`, () => {
+      beforeEach(() => {
+        formHelper.setValue('plugin', 'shec');
+      });
+
+      it(`does not require 'm' and 'k'`, () => {
+        formHelper.expectValidChange('k', null);
+        formHelper.expectValidChange('m', null);
+      });
+
+      it(`should show 'c'`, () => {
+        formHelper.expectIdElementsVisible(fixture, ['c'], true);
+      });
+
+      it(`should not show any other plugin specific form control`, () => {
+        formHelper.expectIdElementsVisible(
+          fixture,
+          ['l', 'crushLocality', 'packetSize', 'technique'],
+          false
+        );
+      });
+    });
+  });
+
+  describe('submission', () => {
+    let ecp: ErasureCodeProfile;
+
+    const testCreation = () => {
+      fixture.detectChanges();
+      component.onSubmit();
+      expect(ecpService.create).toHaveBeenCalledWith(ecp);
+    };
+
+    beforeEach(() => {
+      ecp = new ErasureCodeProfile();
+      const taskWrapper = TestBed.get(TaskWrapperService);
+      spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+      spyOn(ecpService, 'create').and.stub();
+    });
+
+    describe(`'jerasure' usage`, () => {
+      beforeEach(() => {
+        ecp.name = 'jerasureProfile';
+      });
+
+      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;
+        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';
+        formHelper.setMultipleValues(ecp, true);
+        ecp.k = 4;
+        formHelper.setValue('packetSize', 8192, true);
+        ecp.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();
+      });
+    });
+
+    describe(`'isa' usage`, () => {
+      beforeEach(() => {
+        ecp.name = 'isaProfile';
+        ecp.plugin = 'isa';
+      });
+
+      it('should be able to create a profile with only plugin and name', () => {
+        formHelper.setMultipleValues(ecp, true);
+        testCreation();
+      });
+
+      it('should send profile with plugin, name, failure domain and technique only', () => {
+        ecp.technique = 'cauchy';
+        formHelper.setMultipleValues(ecp, true);
+        formHelper.setValue('crushFailureDomain', 'osd', true);
+        ecp['crush-failure-domain'] = 'osd';
+        testCreation();
+      });
+
+      it('should not send the profile with unsupported fields', () => {
+        formHelper.setMultipleValues(ecp, true);
+        formHelper.setValue('packetSize', 'osd', true);
+        testCreation();
+      });
+    });
+
+    describe(`'lrc' usage`, () => {
+      beforeEach(() => {
+        ecp.name = 'lreProfile';
+        ecp.plugin = 'lrc';
+      });
+
+      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;
+        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';
+        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();
+      });
+    });
+
+    describe(`'shec' usage`, () => {
+      beforeEach(() => {
+        ecp.name = 'shecProfile';
+        ecp.plugin = 'shec';
+      });
+
+      it('should be able to create a profile with only plugin and name', () => {
+        formHelper.setMultipleValues(ecp, true);
+        testCreation();
+      });
+
+      it('should send profile with plugin, name, c and crush device class only', () => {
+        ecp.c = 4;
+        formHelper.setMultipleValues(ecp, true);
+        formHelper.setValue('crushDeviceClass', 'ssd', true);
+        ecp['crush-device-class'] = 'ssd';
+        testCreation();
+      });
+
+      it('should not send the profile with unsupported fields', () => {
+        formHelper.setMultipleValues(ecp, true);
+        formHelper.setValue('l', 8, true);
+        testCreation();
+      });
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.ts
new file mode 100644 (file)
index 0000000..6e3218b
--- /dev/null
@@ -0,0 +1,252 @@
+import { Component, EventEmitter, OnInit, Output } from '@angular/core';
+import { Validators } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
+import { CdFormBuilder } from '../../../shared/forms/cd-form-builder';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { CdValidators } from '../../../shared/forms/cd-validators';
+import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { ErasureCodeProfileFormTooltips } from './erasure-code-profile-form-tooltips';
+
+@Component({
+  selector: 'cd-erasure-code-profile-form',
+  templateUrl: './erasure-code-profile-form.component.html',
+  styleUrls: ['./erasure-code-profile-form.component.scss']
+})
+export class ErasureCodeProfileFormComponent implements OnInit {
+  @Output()
+  submitAction = new EventEmitter();
+
+  form: CdFormGroup;
+  failureDomains: string[];
+  plugins: string[];
+  names: string[];
+  techniques: string[];
+  requiredControls: string[] = [];
+  devices: string[] = [];
+  tooltips = new ErasureCodeProfileFormTooltips();
+
+  PLUGIN = {
+    LRC: 'lrc', // Locally Repairable Erasure Code
+    SHEC: 'shec', // Shingled Erasure Code
+    JERASURE: 'jerasure', // default
+    ISA: 'isa' // Intel Storage Acceleration
+  };
+  plugin = this.PLUGIN.JERASURE;
+
+  constructor(
+    private formBuilder: CdFormBuilder,
+    public bsModalRef: BsModalRef,
+    private taskWrapper: TaskWrapperService,
+    private ecpService: ErasureCodeProfileService
+  ) {
+    this.createForm();
+    this.setJerasureDefaults();
+  }
+
+  createForm() {
+    this.form = this.formBuilder.group({
+      name: [
+        null,
+        [
+          Validators.required,
+          Validators.pattern('[A-Za-z0-9_-]+'),
+          CdValidators.custom(
+            'uniqueName',
+            (value) => this.names && this.names.indexOf(value) !== -1
+          )
+        ]
+      ],
+      plugin: [this.PLUGIN.JERASURE, [Validators.required]],
+      k: [1], // Will be replaced by plugin defaults
+      m: [1], // Will be replaced by plugin defaults
+      crushFailureDomain: ['host'],
+      crushRoot: ['default'], // default for all - is a list possible???
+      crushDeviceClass: [''], // set none to empty at submit - get list from configs?
+      directory: [''],
+      // Only for 'jerasure' and 'isa' use
+      technique: ['reed_sol_van'],
+      // Only for 'jerasure' use
+      packetSize: [2048, [Validators.min(1)]],
+      // Only for 'lrc' use
+      l: [1, [Validators.required, Validators.min(1)]],
+      crushLocality: [''], // set to none at the end (same list as for failure domains)
+      // Only for 'shec' use
+      c: [1, [Validators.required, Validators.min(1)]]
+    });
+    this.form.get('plugin').valueChanges.subscribe((plugin) => this.onPluginChange(plugin));
+  }
+
+  onPluginChange(plugin) {
+    this.plugin = plugin;
+    if (plugin === this.PLUGIN.JERASURE) {
+      this.setJerasureDefaults();
+    } else if (plugin === this.PLUGIN.LRC) {
+      this.setLrcDefaults();
+    } else if (plugin === this.PLUGIN.ISA) {
+      this.setIsaDefaults();
+    } else if (plugin === this.PLUGIN.SHEC) {
+      this.setShecDefaults();
+    }
+  }
+
+  private setNumberValidators(name: string, required: boolean) {
+    const validators = [Validators.min(1)];
+    if (required) {
+      validators.push(Validators.required);
+    }
+    this.form.get(name).setValidators(validators);
+  }
+
+  private setKMValidators(required: boolean) {
+    ['k', 'm'].forEach((name) => this.setNumberValidators(name, required));
+  }
+
+  private setJerasureDefaults() {
+    this.requiredControls = ['k', 'm'];
+    this.setDefaults({
+      k: 4,
+      m: 2
+    });
+    this.setKMValidators(true);
+    this.techniques = [
+      'reed_sol_van',
+      'reed_sol_r6_op',
+      'cauchy_orig',
+      'cauchy_good',
+      'liberation',
+      'blaum_roth',
+      'liber8tion'
+    ];
+  }
+
+  private setLrcDefaults() {
+    this.requiredControls = ['k', 'm', 'l'];
+    this.setKMValidators(true);
+    this.setNumberValidators('l', true);
+    this.setDefaults({
+      k: 4,
+      m: 2,
+      l: 3
+    });
+  }
+
+  private setIsaDefaults() {
+    this.requiredControls = [];
+    this.setKMValidators(false);
+    this.setDefaults({
+      k: 7,
+      m: 3
+    });
+    this.techniques = ['reed_sol_van', 'cauchy'];
+  }
+
+  private setShecDefaults() {
+    this.requiredControls = [];
+    this.setKMValidators(false);
+    this.setDefaults({
+      k: 4,
+      m: 3,
+      c: 2
+    });
+  }
+
+  private setDefaults(defaults: object) {
+    Object.keys(defaults).forEach((controlName) => {
+      if (this.form.get(controlName).pristine) {
+        this.form.silentSet(controlName, defaults[controlName]);
+      }
+    });
+  }
+
+  ngOnInit() {
+    this.ecpService
+      .getInfo()
+      .subscribe(
+        ({
+          failure_domains,
+          plugins,
+          names,
+          directory,
+          devices
+        }: {
+          failure_domains: string[];
+          plugins: string[];
+          names: string[];
+          directory: string;
+          devices: string[];
+        }) => {
+          this.failureDomains = failure_domains;
+          this.plugins = plugins;
+          this.names = names;
+          this.devices = devices;
+          this.form.silentSet('directory', directory);
+        }
+      );
+  }
+
+  private createJson() {
+    const pluginControls = {
+      technique: [this.PLUGIN.ISA, this.PLUGIN.JERASURE],
+      packetSize: [this.PLUGIN.JERASURE],
+      l: [this.PLUGIN.LRC],
+      crushLocality: [this.PLUGIN.LRC],
+      c: [this.PLUGIN.SHEC]
+    };
+    const ecp = new ErasureCodeProfile();
+    const plugin = this.form.getValue('plugin');
+    Object.keys(this.form.controls)
+      .filter((name) => {
+        const pluginControl = pluginControls[name];
+        const control = this.form.get(name);
+        const usable = (pluginControl && pluginControl.includes(plugin)) || !pluginControl;
+        return (
+          usable &&
+          (control.dirty || this.requiredControls.includes(name)) &&
+          this.form.getValue(name)
+        );
+      })
+      .forEach((name) => {
+        this.extendJson(name, ecp);
+      });
+    return ecp;
+  }
+
+  private extendJson(name: string, ecp: ErasureCodeProfile) {
+    const differentApiAttributes = {
+      crushFailureDomain: 'crush-failure-domain',
+      crushRoot: 'crush-root',
+      crushDeviceClass: 'crush-device-class',
+      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,
+        (resp) => {
+          this.form.setErrors({ cdSubmitButton: true });
+        },
+        () => {
+          this.bsModalRef.hide();
+          this.submitAction.emit(profile);
+        }
+      );
+  }
+}
index b10599ec5ab7d064e8c111fe0c651ba2bead2768..971aece141328b87b863d9f585081e3fce3df40a 100644 (file)
@@ -6,6 +6,7 @@ import { Pool } from '../pool';
 
 export class PoolFormData {
   poolTypes = ['erasure', 'replicated'];
+  erasureInfo = false;
   applications = {
     selected: [],
     available: [
index f67bbdbba723a73f8cbb03ffa2a7cd6d0a5e13aa..fe04c437fe1cda4784944fe32d71395d9f0c5c23 100644 (file)
               Erasure code profile
             </label>
             <div class="col-sm-9">
-              <select class="form-control"
-                      id="erasureProfile"
-                      name="erasureProfile"
-                      formControlName="erasureProfile">
-                <option *ngIf="!ecProfiles"
-                        ngValue=""
-                        i18n>
-                  Loading...
-                </option>
-                <option *ngIf="ecProfiles && ecProfiles.length === 0"
-                        i18n
-                        [ngValue]="null">
-                  -- No erasure code profile available --
-                </option>
-                <option *ngIf="ecProfiles && ecProfiles.length > 0"
-                        i18n
-                        [ngValue]="null">
-                  -- Select an erasure code profile --
-                </option>
-                <option *ngFor="let ecp of ecProfiles"
-                        [ngValue]="ecp">
-                  {{ ecp.name }}
-                </option>
-              </select>
+              <div class="input-group">
+                <select class="form-control"
+                        id="erasureProfile"
+                        name="erasureProfile"
+                        formControlName="erasureProfile">
+                  <option *ngIf="!ecProfiles"
+                          ngValue=""
+                          i18n>
+                    Loading...
+                  </option>
+                  <option *ngIf="ecProfiles && ecProfiles.length === 0"
+                          i18n
+                          [ngValue]="null">
+                    -- No erasure code profile available --
+                  </option>
+                  <option *ngIf="ecProfiles && ecProfiles.length > 0"
+                          i18n
+                          [ngValue]="null">
+                    -- Select an erasure code profile --
+                  </option>
+                  <option *ngFor="let ecp of ecProfiles"
+                          [ngValue]="ecp">
+                    {{ ecp.name }}
+                  </option>
+                </select>
+                <span class="input-group-btn">
+                  <button class="btn btn-default"
+                          [ngClass]="{'active': data.erasureInfo}"
+                          id="ecp-info-button"
+                          type="button"
+                          (click)="data.erasureInfo = !data.erasureInfo">
+                    <i class="fa fa-question-circle"
+                       aria-hidden="true"></i>
+                  </button>
+                  <button class="btn btn-default"
+                          type="button"
+                          [disabled]="editing"
+                          (click)="addErasureCodeProfile()">
+                    <i class="fa fa-plus"
+                       aria-hidden="true"></i>
+                  </button>
+                  <button class="btn btn-default"
+                          type="button"
+                          (click)="deleteErasureCodeProfile()"
+                          [disabled]="editing || ecProfiles.length < 1">
+                    <i class="fa fa-trash-o"
+                       aria-hidden="true"></i>
+                  </button>
+                </span>
+              </div>
+              <span class="help-block"
+                    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>
+              </span>
             </div>
           </div>
 
index c5ad64d39a2a26e1b677c276d7182ffd7b327265..361f8e6d187ecc25543fe647da0653024db43c44 100644 (file)
@@ -6,15 +6,18 @@ import { ActivatedRoute, Router, Routes } from '@angular/router';
 import { RouterTestingModule } from '@angular/router/testing';
 
 import { ToastModule } from 'ng2-toastr';
+import { BsModalService } from 'ngx-bootstrap/modal';
 import { of } from 'rxjs';
 
 import { configureTestBed, FormHelper } from '../../../../testing/unit-test-helper';
 import { NotFoundComponent } from '../../../core/not-found/not-found.component';
 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';
 import { SelectBadgesComponent } from '../../../shared/components/select-badges/select-badges.component';
 import { CdFormGroup } from '../../../shared/forms/cd-form-group';
 import { CrushRule } from '../../../shared/models/crush-rule';
+import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile';
 import { Permission } from '../../../shared/models/permissions';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
@@ -30,6 +33,7 @@ describe('PoolFormComponent', () => {
   let poolService: PoolService;
   let form: CdFormGroup;
   let router: Router;
+  let ecpService: ErasureCodeProfileService;
 
   const setPgNum = (pgs): AbstractControl => {
     formHelper.setValue('poolType', 'erasure');
@@ -108,7 +112,9 @@ describe('PoolFormComponent', () => {
       crush_rules_replicated: [],
       crush_rules_erasure: []
     };
-    component.ecProfiles = [];
+    const ecp1 = new ErasureCodeProfile();
+    ecp1.name = 'ecp1';
+    component.ecProfiles = [ecp1];
     form = component.form;
     formHelper = new FormHelper(form);
   };
@@ -134,7 +140,7 @@ describe('PoolFormComponent', () => {
     setUpPoolComponent();
     poolService = TestBed.get(PoolService);
     spyOn(poolService, 'getInfo').and.callFake(() => [component.info]);
-    const ecpService = TestBed.get(ErasureCodeProfileService);
+    ecpService = TestBed.get(ErasureCodeProfileService);
     spyOn(ecpService, 'list').and.callFake(() => [component.ecProfiles]);
     router = TestBed.get(Router);
     spyOn(router, 'navigate').and.stub();
@@ -728,6 +734,73 @@ describe('PoolFormComponent', () => {
     });
   });
 
+  describe('erasure code profile', () => {
+    const setSelectedEcp = (name: string) => {
+      formHelper.setValue('erasureProfile', { name: name });
+    };
+
+    beforeEach(() => {
+      formHelper.setValue('poolType', 'erasure');
+      fixture.detectChanges();
+    });
+
+    it('should not show info per default', () => {
+      formHelper.expectElementVisible(fixture, '#erasureProfile', true);
+      formHelper.expectElementVisible(fixture, '#ecp-info-block', false);
+    });
+
+    it('should show info if the info button is clicked', () => {
+      const infoButton = fixture.debugElement.query(By.css('#ecp-info-button'));
+      infoButton.triggerEventHandler('click', null);
+      expect(component.data.erasureInfo).toBeTruthy();
+      fixture.detectChanges();
+      expect(infoButton.classes['active']).toBeTruthy();
+      formHelper.expectIdElementsVisible(fixture, ['erasureProfile', 'ecp-info-block'], true);
+    });
+
+    describe('ecp deletion', () => {
+      let taskWrapper: TaskWrapperService;
+      let deletion: CriticalConfirmationModalComponent;
+
+      const callDeletion = () => {
+        component.deleteErasureCodeProfile();
+        deletion.submitActionObservable();
+      };
+
+      const testPoolDeletion = (name) => {
+        setSelectedEcp(name);
+        callDeletion();
+        expect(ecpService.delete).toHaveBeenCalledWith(name);
+        expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({
+          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
+          };
+        });
+        spyOn(ecpService, 'delete').and.stub();
+        taskWrapper = TestBed.get(TaskWrapperService);
+        spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+      });
+
+      it('should delete two different erasure code profiles', () => {
+        testPoolDeletion('someEcpName');
+        testPoolDeletion('aDifferentEcpName');
+      });
+    });
+  });
+
   describe('submit - create', () => {
     const setMultipleValues = (settings: {}) => {
       Object.keys(settings).forEach((name) => {
index 1412fbea5884ce7b38fb324ef698938f0717e24b..a43198ff222eafdedbf4e3faf413a6079d5beb2c 100644 (file)
@@ -3,10 +3,12 @@ import { FormControl, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 
 import * as _ from 'lodash';
-import { forkJoin } from 'rxjs';
+import { BsModalService } from 'ngx-bootstrap/modal';
+import { forkJoin, Subscription } from 'rxjs';
 
 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';
 import { CdFormGroup } from '../../../shared/forms/cd-form-group';
 import { CdValidators } from '../../../shared/forms/cd-validators';
 import { CrushRule } from '../../../shared/models/crush-rule';
@@ -18,6 +20,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 { ErasureCodeProfileFormComponent } from '../erasure-code-profile-form/erasure-code-profile-form.component';
 import { Pool } from '../pool';
 import { PoolFormData } from './pool-form-data';
 import { PoolFormInfo } from './pool-form-info';
@@ -37,6 +40,7 @@ export class PoolFormComponent implements OnInit {
   editing = false;
   data = new PoolFormData();
   externalPgChange = false;
+  private modalSubscription: Subscription;
   current = {
     rules: []
   };
@@ -45,9 +49,11 @@ export class PoolFormComponent implements OnInit {
     private dimlessBinaryPipe: DimlessBinaryPipe,
     private route: ActivatedRoute,
     private router: Router,
+    private modalService: BsModalService,
     private poolService: PoolService,
     private authStorageService: AuthStorageService,
     private formatter: FormatterService,
+    private bsModalService: BsModalService,
     private taskWrapper: TaskWrapperService,
     private ecpService: ErasureCodeProfileService
   ) {
@@ -138,10 +144,14 @@ export class PoolFormComponent implements OnInit {
   }
 
   private initEcp(ecProfiles: ErasureCodeProfile[]) {
+    const control = this.form.get('erasureProfile');
+    if (ecProfiles.length <= 1) {
+      control.disable();
+    }
     if (ecProfiles.length === 1) {
-      const control = this.form.get('erasureProfile');
       control.setValue(ecProfiles[0]);
-      control.disable();
+    } else if (ecProfiles.length > 1 && control.disabled) {
+      control.enable();
     }
     this.ecProfiles = ecProfiles;
   }
@@ -424,6 +434,35 @@ export class PoolFormComponent implements OnInit {
     ].join(' ');
   }
 
+  addErasureCodeProfile() {
+    this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadECPs());
+    this.bsModalService.show(ErasureCodeProfileFormComponent);
+  }
+
+  private reloadECPs() {
+    this.ecpService.list().subscribe((profiles: ErasureCodeProfile[]) => this.initEcp(profiles));
+    this.modalSubscription.unsubscribe();
+  }
+
+  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: 'erasure code profile',
+        submitActionObservable: () =>
+          this.taskWrapper.wrapTaskAroundCall({
+            task: new FinishedTask('ecp/delete', { name: name }),
+            call: this.ecpService.delete(name)
+          })
+      }
+    });
+  }
+
   submit() {
     if (this.form.invalid) {
       this.form.setErrors({ cdSubmitButton: true });
index 1ca6427b272e93c6ba03c6617501b31e6e522e6e..d086dd05cc2f2bf8fa734dd1bbbae13cd8985089 100644 (file)
@@ -9,6 +9,7 @@ import { TabsModule } from 'ngx-bootstrap/tabs';
 
 import { ServicesModule } from '../../shared/services/services.module';
 import { SharedModule } from '../../shared/shared.module';
+import { ErasureCodeProfileFormComponent } from './erasure-code-profile-form/erasure-code-profile-form.component';
 import { PoolFormComponent } from './pool-form/pool-form.component';
 import { PoolListComponent } from './pool-list/pool-list.component';
 
@@ -24,6 +25,7 @@ import { PoolListComponent } from './pool-list/pool-list.component';
     ServicesModule
   ],
   exports: [PoolListComponent, PoolFormComponent],
-  declarations: [PoolListComponent, PoolFormComponent]
+  declarations: [PoolListComponent, PoolFormComponent, ErasureCodeProfileFormComponent],
+  entryComponents: [ErasureCodeProfileFormComponent]
 })
 export class PoolModule {}
index 6284fbbba97909e4cb51b684401fe0afe4306da6..180a35073474097dfdb68cc5399d83705c2161d5 100644 (file)
@@ -2,11 +2,14 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/
 import { TestBed } from '@angular/core/testing';
 
 import { configureTestBed } from '../../../testing/unit-test-helper';
+import { ErasureCodeProfile } from '../models/erasure-code-profile';
 import { ErasureCodeProfileService } from './erasure-code-profile.service';
 
 describe('ErasureCodeProfileService', () => {
   let service: ErasureCodeProfileService;
   let httpTesting: HttpTestingController;
+  const apiPath = 'api/erasure_code_profile';
+  const testProfile: ErasureCodeProfile = { name: 'test', plugin: 'jerasure', k: 2, m: 1 };
 
   configureTestBed({
     imports: [HttpClientTestingModule],
@@ -18,13 +21,47 @@ describe('ErasureCodeProfileService', () => {
     httpTesting = TestBed.get(HttpTestingController);
   });
 
+  afterEach(() => {
+    httpTesting.verify();
+  });
+
   it('should be created', () => {
     expect(service).toBeTruthy();
   });
 
   it('should call list', () => {
     service.list().subscribe();
-    const req = httpTesting.expectOne('api/erasure_code_profile');
+    const req = httpTesting.expectOne(apiPath);
+    expect(req.request.method).toBe('GET');
+  });
+
+  it('should call create', () => {
+    service.create(testProfile).subscribe();
+    const req = httpTesting.expectOne(apiPath);
+    expect(req.request.method).toBe('POST');
+  });
+
+  it('should call update', () => {
+    service.update(testProfile).subscribe();
+    const req = httpTesting.expectOne(`${apiPath}/test`);
+    expect(req.request.method).toBe('PUT');
+  });
+
+  it('should call delete', () => {
+    service.delete('test').subscribe();
+    const req = httpTesting.expectOne(`${apiPath}/test`);
+    expect(req.request.method).toBe('DELETE');
+  });
+
+  it('should call get', () => {
+    service.get('test').subscribe();
+    const req = httpTesting.expectOne(`${apiPath}/test`);
+    expect(req.request.method).toBe('GET');
+  });
+
+  it('should call getInfo', () => {
+    service.getInfo().subscribe();
+    const req = httpTesting.expectOne(`${apiPath}/_info`);
     expect(req.request.method).toBe('GET');
   });
 });
index c019355874683e8767dd74ea4c6ccf3c307facae..165a5d6a562afdb785c0335df8f3f0bfb5937147 100644 (file)
@@ -1,15 +1,38 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
+import { ErasureCodeProfile } from '../models/erasure-code-profile';
 import { ApiModule } from './api.module';
 
 @Injectable({
   providedIn: ApiModule
 })
 export class ErasureCodeProfileService {
+  apiPath = 'api/erasure_code_profile';
+
   constructor(private http: HttpClient) {}
 
   list() {
-    return this.http.get('api/erasure_code_profile');
+    return this.http.get(this.apiPath);
+  }
+
+  create(ecp: ErasureCodeProfile) {
+    return this.http.post(this.apiPath, ecp, { observe: 'response' });
+  }
+
+  update(ecp: ErasureCodeProfile) {
+    return this.http.put(`${this.apiPath}/${ecp.name}`, ecp, { observe: 'response' });
+  }
+
+  delete(name: string) {
+    return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' });
+  }
+
+  get(name: string) {
+    return this.http.get(`${this.apiPath}/${name}`);
+  }
+
+  getInfo() {
+    return this.http.get(`${this.apiPath}/_info`);
   }
 }
index 37ed147b44120dcdb929fecb62ff79341d020278..17f48acd53beda46dfa6f52c29fea4e81dd5f779 100644 (file)
@@ -1,7 +1,15 @@
-export interface ErasureCodeProfile {
-  k: number;
-  m: number;
+export class ErasureCodeProfile {
   name: string;
   plugin: string;
-  technique: string;
+  k?: number;
+  m?: number;
+  c?: number;
+  l?: number;
+  packetsize?: number;
+  technique?: string;
+  'crush-root'?: string;
+  'crush-locality'?: string;
+  'crush-failure-domain'?: string;
+  'crush-device-class'?: string;
+  'directory'?: string;
 }
index c8f0ae67706cf87a7eba345cc675ab26755d57a8..37956f16ac31dcf19172d4f657de438e9fe6741f 100644 (file)
@@ -90,6 +90,27 @@ describe('TaskManagerMessageService', () => {
       });
     });
 
+    describe('erasure code profile tasks', () => {
+      beforeEach(() => {
+        const metadata = {
+          name: 'someEcpName'
+        };
+        defaultMsg = `erasure code profile '${metadata.name}'`;
+        finishedTask.metadata = metadata;
+      });
+
+      it('tests ecp/create messages', () => {
+        finishedTask.name = 'ecp/create';
+        testCreate(defaultMsg);
+        testErrorCode(17, `Name is already used by ${defaultMsg}.`);
+      });
+
+      it('tests ecp/delete messages', () => {
+        finishedTask.name = 'ecp/delete';
+        testDelete(defaultMsg);
+      });
+    });
+
     describe('rbd tasks', () => {
       let metadata;
       let childMsg: string;
index 9cea92c3c0520aaca7fb1340d4f9a74419913f99..51db2e7a82e960f824575dfd43ecb16315232041 100644 (file)
@@ -76,6 +76,7 @@ export class TaskMessageService {
   };
 
   messages = {
+    // Pool tasks
     'pool/create': new TaskMessage(this.commonOperations.create, this.pool, (metadata) => ({
       '17': `Name is already used by ${this.pool(metadata)}.`
     })),
@@ -83,6 +84,12 @@ export class TaskMessageService {
       '17': `Name is already used by ${this.pool(metadata)}.`
     })),
     'pool/delete': new TaskMessage(this.commonOperations.delete, this.pool),
+    // Erasure code profile tasks
+    'ecp/create': new TaskMessage(this.commonOperations.create, this.ecp, (metadata) => ({
+      '17': `Name is already used by ${this.ecp(metadata)}.`
+    })),
+    'ecp/delete': new TaskMessage(this.commonOperations.delete, this.ecp),
+    // RBD tasks
     'rbd/create': new TaskMessage(this.commonOperations.create, this.rbd.default, (metadata) => ({
       '17': `Name is already used by ${this.rbd.default(metadata)}.`
     })),
@@ -111,6 +118,7 @@ export class TaskMessageService {
       new TaskMessageOperation('Flattening', 'flatten', 'Flattened'),
       this.rbd.default
     ),
+    // RBD snapshot tasks
     'rbd/snap/create': new TaskMessage(
       this.commonOperations.create,
       this.rbd.snapshot,
@@ -136,6 +144,7 @@ export class TaskMessageService {
       new TaskMessageOperation('Rolling back', 'rollback', 'Rolled back'),
       this.rbd.snapshot
     ),
+    // RBD trash tasks
     'rbd/trash/move': new TaskMessage(
       new TaskMessageOperation('Moving', 'move', 'Moved'),
       (metadata) => `image '${metadata.pool_name}/${metadata.image_name}' to trash`,
@@ -172,6 +181,10 @@ export class TaskMessageService {
     return `pool '${metadata.pool_name}'`;
   }
 
+  ecp(metadata) {
+    return `erasure code profile '${metadata.name}'`;
+  }
+
   _getTaskTitle(task: Task) {
     return this.messages[task.name] || this.defaultMessage;
   }