mgr/dashboard: Adds ECP management to the frontend

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>
This commit is contained in:
Stephan Müller 2018-08-01 11:36:41 +02:00
parent 3a7f85809a
commit 5e4ebf7089
15 changed files with 1321 additions and 37 deletions

View File

@ -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.`;
}

View File

@ -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>

View File

@ -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();
});
});
});
});

View File

@ -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);
}
);
}
}

View File

@ -6,6 +6,7 @@ import { Pool } from '../pool';
export class PoolFormData {
poolTypes = ['erasure', 'replicated'];
erasureInfo = false;
applications = {
selected: [],
available: [

View File

@ -230,30 +230,64 @@
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>

View 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) => {

View 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,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 });

View 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 {}

View 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');
});
});

View 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`);
}
}

View 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;
}

View 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;

View 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;
}