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
index 00000000000..c74a6bd3715
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form-tooltips.ts
@@ -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
index 00000000000..43be4265dc3
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.html
@@ -0,0 +1,394 @@
+
+
+
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
index 00000000000..e69de29bb2d
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
index 00000000000..b5819e9c0ab
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.spec.ts
@@ -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;
+ 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
index 00000000000..6e3218b47f7
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/erasure-code-profile-form/erasure-code-profile-form.component.ts
@@ -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);
+ }
+ );
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts
index b10599ec5ab..971aece1413 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts
@@ -6,6 +6,7 @@ import { Pool } from '../pool';
export class PoolFormData {
poolTypes = ['erasure', 'replicated'];
+ erasureInfo = false;
applications = {
selected: [],
available: [
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html
index f67bbdbba72..fe04c437fe1 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html
@@ -230,30 +230,64 @@
Erasure code profile
-
-
- Loading...
-
-
- -- No erasure code profile available --
-
- 0"
- i18n
- [ngValue]="null">
- -- Select an erasure code profile --
-
-
- {{ ecp.name }}
-
-
+
+
+
+ Loading...
+
+
+ -- No erasure code profile available --
+
+ 0"
+ i18n
+ [ngValue]="null">
+ -- Select an erasure code profile --
+
+
+ {{ ecp.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
index c5ad64d39a2..361f8e6d187 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
@@ -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) => {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
index 1412fbea588..a43198ff222 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
@@ -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,11 +144,15 @@ export class PoolFormComponent implements OnInit {
}
private initEcp(ecProfiles: ErasureCodeProfile[]) {
- if (ecProfiles.length === 1) {
- const control = this.form.get('erasureProfile');
- control.setValue(ecProfiles[0]);
+ const control = this.form.get('erasureProfile');
+ if (ecProfiles.length <= 1) {
control.disable();
}
+ if (ecProfiles.length === 1) {
+ control.setValue(ecProfiles[0]);
+ } 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 });
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts
index 1ca6427b272..d086dd05cc2 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts
@@ -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 {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts
index 6284fbbba97..180a3507347 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts
@@ -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');
});
});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts
index c0193558746..165a5d6a562 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts
@@ -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`);
}
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts
index 37ed147b441..17f48acd53b 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts
@@ -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;
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts
index c8f0ae67706..37956f16ac3 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts
@@ -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;
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
index 9cea92c3c05..51db2e7a82e 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
@@ -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;
}