mirror of
https://github.com/ceph/ceph
synced 2025-01-04 02:02:36 +00:00
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:
parent
3a7f85809a
commit
5e4ebf7089
@ -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.`;
|
||||
}
|
@ -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">×</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>
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ import { Pool } from '../pool';
|
||||
|
||||
export class PoolFormData {
|
||||
poolTypes = ['erasure', 'replicated'];
|
||||
erasureInfo = false;
|
||||
applications = {
|
||||
selected: [],
|
||||
available: [
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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) => {
|
||||
|
@ -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 });
|
||||
|
@ -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 {}
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user