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