From 1e75db573b481f0b6a705f8ec321e7cbf4f013ad Mon Sep 17 00:00:00 2001 From: =?utf8?q?Stephan=20M=C3=BCller?= Date: Fri, 29 Jun 2018 14:13:37 +0200 Subject: [PATCH] mgr/dashboard: Pool create / edit MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit You can create/edit pools through the UI if you have the right permissions. You can specify the following: * Name - can't be duplicated * Type - replicated or erasure * Crush rule set * Validates if you can use it * A popover tells which crush steps are used * Replica size - depends on your selected rule and the amount of OSDs * Erasure code profile * PGs - will be recalculated on form changes (type, replica size, erasure profile, crush rule) only if not set before * EC overwrites flag * Compression - Algorithm / Min/max blob size / mode / ratio * Application metadata - Predefined and custom applications as badges Fixes: https://tracker.ceph.com/issues/36355 Signed-off-by: Stephan Müller --- .../frontend/src/app/app-routing.module.ts | 10 +- .../app/ceph/pool/pool-form/pool-form-data.ts | 33 + .../app/ceph/pool/pool-form/pool-form-info.ts | 11 + .../pool/pool-form/pool-form.component.html | 482 +++++++++ .../pool/pool-form/pool-form.component.scss | 3 + .../pool-form/pool-form.component.spec.ts | 946 ++++++++++++++++++ .../pool/pool-form/pool-form.component.ts | 530 ++++++++++ .../pool/pool-list/pool-list.component.html | 52 +- .../frontend/src/app/ceph/pool/pool.module.ts | 20 +- .../src/app/shared/api/pool.service.spec.ts | 28 +- .../src/app/shared/api/pool.service.ts | 24 +- .../task-manager-message.service.spec.ts | 0 .../services/task-manager-message.service.ts | 0 13 files changed, 2123 insertions(+), 16 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-info.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index efe4ed58db8..1b60de3b670 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -12,6 +12,7 @@ import { MonitorComponent } from './ceph/cluster/monitor/monitor.component'; import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component'; import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component'; import { PerformanceCounterComponent } from './ceph/performance-counter/performance-counter/performance-counter.component'; +import { PoolFormComponent } from './ceph/pool/pool-form/pool-form.component'; import { PoolListComponent } from './ceph/pool/pool-list/pool-list.component'; import { Rgw501Component } from './ceph/rgw/rgw-501/rgw-501.component'; import { RgwBucketFormComponent } from './ceph/rgw/rgw-bucket-form/rgw-bucket-form.component'; @@ -92,9 +93,14 @@ const routes: Routes = [ // Pools { path: 'pool', - component: PoolListComponent, canActivate: [AuthGuardService], - data: { breadcrumbs: 'Pools' } + canActivateChild: [AuthGuardService], + data: { breadcrumbs: 'Pools' }, + children: [ + { path: '', component: PoolListComponent }, + { path: 'add', component: PoolFormComponent, data: { breadcrumbs: 'Add' } }, + { path: 'edit/:name', component: PoolFormComponent, data: { breadcrumbs: 'Edit' } } + ] }, // Block { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts new file mode 100644 index 00000000000..b10599ec5ab --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts @@ -0,0 +1,33 @@ +import { Validators } from '@angular/forms'; + +import { SelectBadgesMessages } from '../../../shared/components/select-badges/select-badges-messages.model'; +import { SelectBadgesOption } from '../../../shared/components/select-badges/select-badges-option.model'; +import { Pool } from '../pool'; + +export class PoolFormData { + poolTypes = ['erasure', 'replicated']; + applications = { + selected: [], + available: [ + new SelectBadgesOption(false, 'cephfs', ''), + new SelectBadgesOption(false, 'rbd', ''), + new SelectBadgesOption(false, 'rgw', '') + ], + validators: [Validators.pattern('[A-Za-z0-9_]+'), Validators.maxLength(128)], + messages: new SelectBadgesMessages({ + empty: 'No applications added', + selectionLimit: { + text: 'Applications limit reached', + tooltip: 'A pool can only have up to four applications definitions.' + }, + customValidations: { + pattern: `Allowed characters '_a-zA-Z0-9'`, + maxlength: 'Maximum length is 128 characters' + }, + filter: 'Filter or add applications', + add: 'Add application' + }) + }; + pgs = 1; + pool: Pool; // Only available during edit mode +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-info.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-info.ts new file mode 100644 index 00000000000..931083b0f6d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-info.ts @@ -0,0 +1,11 @@ +import { CrushRule } from '../../../shared/models/crush-rule'; + +export class PoolFormInfo { + pool_names: string[]; + osd_count: number; + is_all_bluestore: boolean; + compression_algorithms: string[]; + compression_modes: string[]; + crush_rules_replicated: CrushRule[]; + crush_rules_erasure: CrushRule[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html new file mode 100644 index 00000000000..7299a5c5676 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html @@ -0,0 +1,482 @@ +
+

+ + Loading... +

+
+
+
+

+ {{ editing ? 'Edit' : 'Add' }} pool +

+
+ +
+ +
+ +
+ + + This field is required! + + + The chosen Ceph pool name is already in use. + +
+
+ + +
+ +
+ + + This field is required! + +
+
+ +
+ +
+ +
+ + + This field is required! + + + At least one placement group is needed! + + + Your cluster can't handle this many PGs. Please recalculate the PG amount needed. + + + You can only increase the number of PGs of an existing pool. + Currently your pool has {{ data.pool.pg_num }} PGs. + + + Calculation help + + + The current PGs settings were calculated for you, you should make sure the values + suite your needs before submit. + +
+
+ + + + +
+
    +
  1. + {{ describeCrushStep(step) }} +
  2. +
+
+
+
+
+ +
+ + + The rule can't be used in the current cluster as it has to few OSDs to meet the + minimum required OSD by this rule. + +
+
+ + +
+ +
+ + +
    +
  • + Minimum: {{ getMinSize() }} +
  • +
  • + Maximum: {{ getMaxSize() }} +
  • +
+
+ + The size specified is out of range. + A value from {{ getMinSize() }} to {{ getMaxSize() }} is valid. + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+ +
+ + + + +
+
+ + +
+ Compression + + +
+ +
+ +
+
+
+ +
+ +
+ +
+
+ + +
+ +
+ + + Value should be greater than 0 + + + Value should be greater than the maximum blob size + +
+
+ + +
+ +
+ + + Value should be greater than 0 + + + Value should be greater than the minimum blob size + +
+
+ + +
+ +
+ + + Value should be between 0.0 and 1.0 + +
+
+ +
+
+ +
+
+ + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss new file mode 100644 index 00000000000..29a4cf72e9b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss @@ -0,0 +1,3 @@ +.crush-rule-steps { + margin-top: 10px; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts new file mode 100644 index 00000000000..fcb601cfac2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts @@ -0,0 +1,946 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AbstractControl } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, Router, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastModule } from 'ng2-toastr'; +import { of } from 'rxjs'; + +import { configureTestBed } 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 { 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 { Permission } from '../../../shared/models/permissions'; +import { AuthStorageService } from '../../../shared/services/auth-storage.service'; +import { TaskWrapperService } from '../../../shared/services/task-wrapper.service'; +import { Pool } from '../pool'; +import { PoolModule } from '../pool.module'; +import { PoolFormComponent } from './pool-form.component'; + +describe('PoolFormComponent', () => { + const OSDS = 8; + let component: PoolFormComponent; + let fixture: ComponentFixture; + let poolService: PoolService; + let form: CdFormGroup; + let router: Router; + + const hasError = (control: AbstractControl, error: string) => { + expect(control.hasError(error)).toBeTruthy(); + }; + + const isValid = (control: AbstractControl) => { + expect(control.valid).toBeTruthy(); + }; + + const setValue = (controlName: string, value: any): AbstractControl => { + const control = form.get(controlName); + control.setValue(value); + return control; + }; + + const setPgNum = (pgs): AbstractControl => { + setValue('poolType', 'erasure'); + const control = setValue('pgNum', pgs); + fixture.detectChanges(); + fixture.debugElement.query(By.css('#pgNum')).nativeElement.dispatchEvent(new Event('blur')); + return control; + }; + + const createCrushRule = ({ + id = 0, + name = 'somePoolName', + min = 1, + max = 10, + type = 'replicated' + }: { + max?: number; + min?: number; + id?: number; + name?: string; + type?: string; + }) => { + const typeNumber = type === 'erasure' ? 3 : 1; + const rule = new CrushRule(); + rule.max_size = max; + rule.min_size = min; + rule.rule_id = id; + rule.ruleset = typeNumber; + rule.rule_name = name; + rule.steps = [ + { + item_name: 'default', + item: -1, + op: 'take' + }, + { + num: 0, + type: 'osd', + op: 'choose_firstn' + }, + { + op: 'emit' + } + ]; + component.info['crush_rules_' + type].push(rule); + }; + + const testSubmit = (pool: any, taskName: string, poolServiceMethod: 'create' | 'update') => { + spyOn(poolService, poolServiceMethod).and.stub(); + const taskWrapper = TestBed.get(TaskWrapperService); + spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough(); + component.submit(); + expect(poolService[poolServiceMethod]).toHaveBeenCalledWith(pool); + expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({ + task: { + name: taskName, + metadata: { + pool_name: pool.pool + } + }, + call: undefined // because of stub + }); + }; + + const setUpPoolComponent = () => { + fixture = TestBed.createComponent(PoolFormComponent); + component = fixture.componentInstance; + component.info = { + pool_names: [], + osd_count: OSDS, + is_all_bluestore: true, + compression_algorithms: [], + compression_modes: [], + crush_rules_replicated: [], + crush_rules_erasure: [] + }; + component.ecProfiles = []; + form = component.form; + }; + + const routes: Routes = [{ path: '404', component: NotFoundComponent }]; + + configureTestBed({ + declarations: [NotFoundComponent], + imports: [ + HttpClientTestingModule, + RouterTestingModule.withRoutes(routes), + ToastModule.forRoot(), + PoolModule + ], + providers: [ + ErasureCodeProfileService, + SelectBadgesComponent, + { provide: ActivatedRoute, useValue: { params: of({ name: 'somePoolName' }) } } + ] + }); + + beforeEach(() => { + setUpPoolComponent(); + poolService = TestBed.get(PoolService); + spyOn(poolService, 'getInfo').and.callFake(() => [component.info]); + const ecpService = TestBed.get(ErasureCodeProfileService); + spyOn(ecpService, 'list').and.callFake(() => [component.ecProfiles]); + router = TestBed.get(Router); + spyOn(router, 'navigate').and.stub(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('redirect not allowed users', () => { + let poolPermissions: Permission; + let authStorageService: AuthStorageService; + + const testForRedirect = (times: number) => { + component.authenticate(); + expect(router.navigate).toHaveBeenCalledTimes(times); + }; + + beforeEach(() => { + poolPermissions = { + create: false, + update: false, + read: false, + delete: false + }; + authStorageService = TestBed.get(AuthStorageService); + spyOn(authStorageService, 'getPermissions').and.callFake(() => ({ + pool: poolPermissions + })); + }); + + it('navigates to 404 if not allowed', () => { + component.authenticate(); + expect(router.navigate).toHaveBeenCalledWith(['/404']); + }); + + it('navigates if user is not allowed', () => { + testForRedirect(1); + poolPermissions.read = true; + testForRedirect(2); + poolPermissions.delete = true; + testForRedirect(3); + poolPermissions.update = true; + testForRedirect(4); + component.editing = true; + poolPermissions.update = false; + poolPermissions.create = true; + testForRedirect(5); + }); + + it('does not navigate users with right permissions', () => { + poolPermissions.read = true; + poolPermissions.create = true; + testForRedirect(0); + component.editing = true; + poolPermissions.update = true; + testForRedirect(0); + poolPermissions.create = false; + testForRedirect(0); + }); + }); + + describe('pool form validation', () => { + beforeEach(() => { + component.ngOnInit(); + }); + + it('is invalid at the beginning all sub forms are valid', () => { + expect(form.valid).toBeFalsy(); + ['name', 'poolType', 'pgNum'].forEach((name) => hasError(form.get(name), 'required')); + ['crushRule', 'size', 'erasureProfile', 'ecOverwrites'].forEach((name) => + isValid(form.get(name)) + ); + expect(component.compressionForm.valid).toBeTruthy(); + }); + + it('validates name', () => { + hasError(form.get('name'), 'required'); + isValid(setValue('name', 'some-name')); + component.info.pool_names.push('someExistingPoolName'); + hasError(setValue('name', 'someExistingPoolName'), 'uniqueName'); + hasError(setValue('name', 'wrong format with spaces'), 'pattern'); + }); + + it('validates poolType', () => { + hasError(form.get('poolType'), 'required'); + isValid(setValue('poolType', 'erasure')); + isValid(setValue('poolType', 'replicated')); + }); + + it('validates pgNum in creation mode', () => { + hasError(form.get('pgNum'), 'required'); + setValue('poolType', 'erasure'); + isValid(setPgNum(-28)); + expect(form.getValue('pgNum')).toBe(1); + isValid(setPgNum(15)); + expect(form.getValue('pgNum')).toBe(16); + }); + + it('increases pgNum by the power of two for if the value has changed by one', () => { + setPgNum('16'); + expect(setPgNum(17).value).toBe(32); + expect(setPgNum(31).value).toBe(16); + }); + + it('not increases pgNum by more than one but lower than the next pg update change', () => { + setPgNum('16'); + expect(setPgNum('18').value).toBe(16); + expect(setPgNum('14').value).toBe(16); + }); + + it('validates pgNum in edit mode', () => { + component.data.pool = new Pool('test'); + component.data.pool.pg_num = 16; + component.editing = true; + component.ngOnInit(); + hasError(setPgNum('8'), 'noDecrease'); + }); + + it('is valid if pgNum, poolType and name are valid', () => { + setValue('name', 'some-name'); + setValue('poolType', 'erasure'); + setPgNum(1); + expect(form.valid).toBeTruthy(); + }); + + it('validates crushRule', () => { + isValid(form.get('crushRule')); + hasError(setValue('crushRule', { min_size: 20 }), 'tooFewOsds'); + }); + + it('validates size', () => { + setValue('poolType', 'replicated'); + isValid(form.get('size')); + setValue('crushRule', { + min_size: 2, + max_size: 6 + }); + hasError(setValue('size', 1), 'min'); + hasError(setValue('size', 8), 'max'); + isValid(setValue('size', 6)); + }); + + it('validates compression mode default value', () => { + expect(form.getValue('mode')).toBe('none'); + }); + + describe('compression form', () => { + beforeEach(() => { + setValue('poolType', 'replicated'); + setValue('mode', 'passive'); + }); + + it('is valid', () => { + expect(component.compressionForm.valid).toBeTruthy(); + }); + + it('validates minBlobSize to be only valid between 0 and maxBlobSize', () => { + hasError(setValue('minBlobSize', -1), 'min'); + isValid(setValue('minBlobSize', 0)); + setValue('maxBlobSize', '2 KiB'); + hasError(setValue('minBlobSize', '3 KiB'), 'maximum'); + isValid(setValue('minBlobSize', '1.9 KiB')); + }); + + it('validates minBlobSize converts numbers', () => { + const control = setValue('minBlobSize', '1'); + fixture.detectChanges(); + isValid(control); + expect(control.value).toBe('1 KiB'); + }); + + it('validates maxBlobSize to be only valid bigger than minBlobSize', () => { + hasError(setValue('maxBlobSize', -1), 'min'); + setValue('minBlobSize', '1 KiB'); + hasError(setValue('maxBlobSize', '0.5 KiB'), 'minimum'); + isValid(setValue('maxBlobSize', '1.5 KiB')); + }); + + it('s valid to only use one blob size', () => { + isValid(setValue('minBlobSize', '1 KiB')); + isValid(setValue('maxBlobSize', '')); + isValid(setValue('minBlobSize', '')); + isValid(setValue('maxBlobSize', '1 KiB')); + }); + + it('dismisses any size error if one of the blob sizes is changed into a valid state', () => { + const min = setValue('minBlobSize', '10 KiB'); + const max = setValue('maxBlobSize', '1 KiB'); + fixture.detectChanges(); + max.setValue(''); + isValid(min); + isValid(max); + max.setValue('1 KiB'); + fixture.detectChanges(); + min.setValue('0.5 KiB'); + isValid(min); + isValid(max); + }); + + it('validates maxBlobSize converts numbers', () => { + const control = setValue('maxBlobSize', '2'); + fixture.detectChanges(); + expect(control.value).toBe('2 KiB'); + }); + + it('validates ratio to be only valid between 0 and 1', () => { + isValid(form.get('ratio')); + hasError(setValue('ratio', -0.1), 'min'); + isValid(setValue('ratio', 0)); + isValid(setValue('ratio', 1)); + hasError(setValue('ratio', 1.1), 'max'); + }); + }); + + it('validates application metadata name', () => { + setValue('poolType', 'replicated'); + fixture.detectChanges(); + const selectBadges = fixture.debugElement.query(By.directive(SelectBadgesComponent)) + .componentInstance; + const control = selectBadges.filter; + isValid(control); + control.setValue('?'); + hasError(control, 'pattern'); + control.setValue('Ab3_'); + isValid(control); + control.setValue('a'.repeat(129)); + hasError(control, 'maxlength'); + }); + }); + + describe('pool type changes', () => { + beforeEach(() => { + component.ngOnInit(); + createCrushRule({ id: 3, min: 1, max: 1, name: 'ep1', type: 'erasure' }); + createCrushRule({ id: 0, min: 2, max: 4, name: 'rep1', type: 'replicated' }); + createCrushRule({ id: 1, min: 3, max: 18, name: 'rep2', type: 'replicated' }); + }); + + it('should have a default replicated size of 3', () => { + setValue('poolType', 'replicated'); + expect(form.getValue('size')).toBe(3); + }); + + describe('replicatedRuleChange', () => { + beforeEach(() => { + setValue('poolType', 'replicated'); + setValue('size', 99); + }); + + it('should not set size if a replicated pool is not set', () => { + setValue('poolType', 'erasure'); + expect(form.getValue('size')).toBe(99); + setValue('crushRule', component.info.crush_rules_replicated[1]); + expect(form.getValue('size')).toBe(99); + }); + + it('should set size to maximum if size exceeds maximum', () => { + setValue('crushRule', component.info.crush_rules_replicated[0]); + expect(form.getValue('size')).toBe(4); + }); + + it('should set size to minimum if size is lower than minimum', () => { + setValue('size', -1); + setValue('crushRule', component.info.crush_rules_replicated[0]); + expect(form.getValue('size')).toBe(2); + }); + }); + + describe('rulesChange', () => { + it('has no effect if info is not there', () => { + delete component.info; + setValue('poolType', 'replicated'); + expect(component.current.rules).toEqual([]); + }); + + it('has no effect if pool type is not set', () => { + component['rulesChange'](); + expect(component.current.rules).toEqual([]); + }); + + it('shows all replicated rules when pool type is "replicated"', () => { + setValue('poolType', 'replicated'); + expect(component.current.rules).toEqual(component.info.crush_rules_replicated); + expect(component.current.rules.length).toBe(2); + }); + + it('shows all erasure code rules when pool type is "erasure"', () => { + setValue('poolType', 'erasure'); + expect(component.current.rules).toEqual(component.info.crush_rules_erasure); + expect(component.current.rules.length).toBe(1); + }); + + it('disables rule field if only one rule exists which is used in the disabled field', () => { + setValue('poolType', 'erasure'); + const control = form.get('crushRule'); + expect(control.value).toEqual(component.info.crush_rules_erasure[0]); + expect(control.disabled).toBe(true); + }); + + it('does not select the first rule if more than one exist', () => { + setValue('poolType', 'replicated'); + const control = form.get('crushRule'); + expect(control.value).toEqual(null); + expect(control.disabled).toBe(false); + }); + + it('changing between both types will not leave crushRule in a bad state', () => { + setValue('poolType', 'erasure'); + setValue('poolType', 'replicated'); + const control = form.get('crushRule'); + expect(control.value).toEqual(null); + expect(control.disabled).toBe(false); + setValue('poolType', 'erasure'); + expect(control.value).toEqual(component.info.crush_rules_erasure[0]); + expect(control.disabled).toBe(true); + }); + }); + }); + + describe('getMaxSize and getMinSize', () => { + const setCrushRule = ({ min, max }: { min?: number; max?: number }) => { + setValue('crushRule', { + min_size: min, + max_size: max + }); + }; + + it('returns nothing if osd count is 0', () => { + component.info.osd_count = 0; + expect(component.getMinSize()).toBe(undefined); + expect(component.getMaxSize()).toBe(undefined); + }); + + it('returns nothing if info is not there', () => { + delete component.info; + expect(component.getMinSize()).toBe(undefined); + expect(component.getMaxSize()).toBe(undefined); + }); + + it('returns minimum and maximum of rule', () => { + setCrushRule({ min: 2, max: 6 }); + expect(component.getMinSize()).toBe(2); + expect(component.getMaxSize()).toBe(6); + }); + + it('returns 1 as minimum and the osd count as maximum if no crush rule is available', () => { + expect(component.getMinSize()).toBe(1); + expect(component.getMaxSize()).toBe(OSDS); + }); + + it('returns the osd count as maximum if the rule maximum exceeds it', () => { + setCrushRule({ max: 100 }); + expect(component.getMaxSize()).toBe(OSDS); + }); + + it('should return the osd count as minimum if its lower the the rule minimum', () => { + setCrushRule({ min: 10 }); + expect(component.getMinSize()).toBe(10); + const control = form.get('crushRule'); + expect(control.invalid).toBe(true); + hasError(control, 'tooFewOsds'); + }); + }); + + describe('application metadata', () => { + let selectBadges: SelectBadgesComponent; + + const testAddApp = (app?: string, result?: string[]) => { + selectBadges.filter.setValue(app); + selectBadges.updateFilter(); + selectBadges.selectOption(); + expect(component.data.applications.selected).toEqual(result); + }; + + const testRemoveApp = (app: string, result: string[]) => { + selectBadges.removeItem(app); + expect(component.data.applications.selected).toEqual(result); + }; + + const setCurrentApps = (apps: string[]) => { + component.data.applications.selected = apps; + fixture.detectChanges(); + selectBadges.ngOnInit(); + return apps; + }; + + beforeEach(() => { + setValue('poolType', 'replicated'); + fixture.detectChanges(); + selectBadges = fixture.debugElement.query(By.directive(SelectBadgesComponent)) + .componentInstance; + }); + + it('adds all predefined and a custom applications to the application metadata array', () => { + testAddApp('g', ['rgw']); + testAddApp('b', ['rbd', 'rgw']); + testAddApp('c', ['cephfs', 'rbd', 'rgw']); + testAddApp('something', ['cephfs', 'rbd', 'rgw', 'something']); + }); + + it('only allows 4 apps to be added to the array', () => { + const apps = setCurrentApps(['d', 'c', 'b', 'a']); + testAddApp('e', apps); + }); + + it('can remove apps', () => { + setCurrentApps(['a', 'b', 'c', 'd']); + testRemoveApp('c', ['a', 'b', 'd']); + testRemoveApp('a', ['b', 'd']); + testRemoveApp('d', ['b']); + testRemoveApp('b', []); + }); + + it('does not remove any app that is not in the array', () => { + const apps = ['a', 'b', 'c', 'd']; + setCurrentApps(apps); + testRemoveApp('e', apps); + testRemoveApp('0', apps); + }); + }); + + describe('pg number changes', () => { + beforeEach(() => { + setValue('crushRule', { + min_size: 1, + max_size: 20 + }); + component.ngOnInit(); + // triggers pgUpdate + setPgNum(256); + }); + + describe('pgCalc', () => { + const PGS = 1; + + const getValidCase = () => ({ + type: 'replicated', + osds: OSDS, + size: 4, + ecp: { + k: 2, + m: 2 + }, + expected: 256 + }); + + const testPgCalc = ({ type, osds, size, ecp, expected }) => { + component.info.osd_count = osds; + setValue('poolType', type); + if (type === 'replicated') { + setValue('size', size); + } else { + setValue('erasureProfile', ecp); + } + expect(form.getValue('pgNum')).toBe(expected); + expect(component.externalPgChange).toBe(PGS !== expected); + }; + + beforeEach(() => { + setPgNum(PGS); + }); + + it('does not change anything if type is not valid', () => { + const test = getValidCase(); + test.type = ''; + test.expected = PGS; + testPgCalc(test); + }); + + it('does not change anything if ecp is not valid', () => { + const test = getValidCase(); + test.expected = PGS; + test.type = 'erasure'; + test.ecp = null; + testPgCalc(test); + }); + + it('calculates some replicated values', () => { + const test = getValidCase(); + testPgCalc(test); + test.osds = 16; + test.expected = 512; + testPgCalc(test); + test.osds = 8; + test.size = 8; + test.expected = 128; + testPgCalc(test); + }); + + it('calculates erasure code values even if selection is disabled', () => { + component['initEcp']([{ k: 2, m: 2, name: 'bla', plugin: '', technique: '' }]); + const test = getValidCase(); + test.type = 'erasure'; + testPgCalc(test); + expect(form.get('erasureProfile').disabled).toBeTruthy(); + }); + + it('calculates some erasure code values', () => { + const test = getValidCase(); + test.type = 'erasure'; + testPgCalc(test); + test.osds = 16; + test.ecp.m = 5; + test.expected = 256; + testPgCalc(test); + test.ecp.k = 5; + test.expected = 128; + testPgCalc(test); + }); + + it('should not change a manual set pg number', () => { + form.get('pgNum').markAsDirty(); + const test = getValidCase(); + test.expected = PGS; + testPgCalc(test); + }); + }); + + describe('pgUpdate', () => { + const testPgUpdate = (pgs, jump, returnValue) => { + component['pgUpdate'](pgs, jump); + expect(form.getValue('pgNum')).toBe(returnValue); + }; + + it('updates by value', () => { + testPgUpdate(10, undefined, 8); + testPgUpdate(22, undefined, 16); + testPgUpdate(26, undefined, 32); + }); + + it('updates by jump -> a magnitude of the power of 2', () => { + testPgUpdate(undefined, 1, 512); + testPgUpdate(undefined, -1, 256); + testPgUpdate(undefined, -2, 64); + testPgUpdate(undefined, -10, 1); + }); + + it('returns 1 as minimum for false numbers', () => { + testPgUpdate(-26, undefined, 1); + testPgUpdate(0, undefined, 1); + testPgUpdate(undefined, -20, 1); + }); + + it('uses by value and jump', () => { + testPgUpdate(330, 0, 256); + testPgUpdate(230, 2, 1024); + testPgUpdate(230, 3, 2048); + }); + }); + + describe('pgKeyUp', () => { + const testPgKeyUp = (keyName, returnValue) => { + component.pgKeyUp({ key: keyName }); + expect(form.getValue('pgNum')).toBe(returnValue); + }; + + it('does nothing with unrelated keys', () => { + testPgKeyUp('0', 256); + testPgKeyUp(',', 256); + testPgKeyUp('a', 256); + testPgKeyUp('Space', 256); + testPgKeyUp('ArrowLeft', 256); + testPgKeyUp('ArrowRight', 256); + }); + + it('increments by jump with plus or ArrowUp', () => { + testPgKeyUp('ArrowUp', 512); + testPgKeyUp('ArrowUp', 1024); + testPgKeyUp('+', 2048); + testPgKeyUp('+', 4096); + }); + + it('decrement by jump with minus or ArrowDown', () => { + testPgKeyUp('ArrowDown', 128); + testPgKeyUp('ArrowDown', 64); + testPgKeyUp('-', 32); + testPgKeyUp('-', 16); + }); + }); + }); + + describe('submit - create', () => { + const setMultipleValues = (settings: {}) => { + Object.keys(settings).forEach((name) => { + setValue(name, settings[name]); + }); + }; + const testCreate = (pool) => { + testSubmit(pool, 'pool/create', 'create'); + }; + + beforeEach(() => { + createCrushRule({ name: 'replicatedRule' }); + createCrushRule({ name: 'erasureRule', type: 'erasure', id: 1 }); + }); + + describe('erasure coded pool', () => { + it('minimum requirements', () => { + setMultipleValues({ + name: 'minECPool', + poolType: 'erasure', + pgNum: 4 + }); + testCreate({ + pool: 'minECPool', + pool_type: 'erasure', + pg_num: 4 + }); + }); + + it('with erasure coded profile', () => { + const ecp = { name: 'ecpMinimalMock' }; + setMultipleValues({ + name: 'ecpPool', + poolType: 'erasure', + pgNum: 16, + size: 2, // Will be ignored + erasureProfile: ecp + }); + testCreate({ + pool: 'ecpPool', + pool_type: 'erasure', + pg_num: 16, + erasure_code_profile: ecp.name + }); + }); + + it('with ec_overwrite flag', () => { + setMultipleValues({ + name: 'ecOverwrites', + poolType: 'erasure', + pgNum: 32, + ecOverwrites: true + }); + testCreate({ + pool: 'ecOverwrites', + pool_type: 'erasure', + pg_num: 32, + flags: ['ec_overwrites'] + }); + }); + }); + + describe('replicated coded pool', () => { + it('minimum requirements', () => { + const ecp = { name: 'ecpMinimalMock' }; + setMultipleValues({ + name: 'minRepPool', + poolType: 'replicated', + size: 2, + erasureProfile: ecp, // Will be ignored + pgNum: 8 + }); + testCreate({ + pool: 'minRepPool', + pool_type: 'replicated', + pg_num: 8, + size: 2 + }); + }); + }); + + it('pool with compression', () => { + setMultipleValues({ + name: 'compression', + poolType: 'erasure', + pgNum: 64, + mode: 'passive', + algorithm: 'lz4', + minBlobSize: '4 K', + maxBlobSize: '4 M', + ratio: 0.7 + }); + testCreate({ + pool: 'compression', + pool_type: 'erasure', + pg_num: 64, + compression_mode: 'passive', + compression_algorithm: 'lz4', + compression_min_blob_size: 4096, + compression_max_blob_size: 4194304, + compression_required_ratio: 0.7 + }); + }); + + it('pool with application metadata', () => { + setMultipleValues({ + name: 'apps', + poolType: 'erasure', + pgNum: 128 + }); + component.data.applications.selected = ['cephfs', 'rgw']; + testCreate({ + pool: 'apps', + pool_type: 'erasure', + pg_num: 128, + application_metadata: ['cephfs', 'rgw'] + }); + }); + }); + + describe('edit mode', () => { + const setUrl = (url) => { + Object.defineProperty(router, 'url', { value: url }); + setUpPoolComponent(); // Renew of component needed because the constructor has to be called + }; + + let pool: Pool; + beforeEach(() => { + pool = new Pool('somePoolName'); + pool.type = 'replicated'; + pool.size = 3; + pool.crush_rule = 'someRule'; + pool.pg_num = 32; + pool.options = {}; + pool.options.compression_mode = 'passive'; + pool.options.compression_algorithm = 'lz4'; + pool.options.compression_min_blob_size = 1024 * 512; + pool.options.compression_max_blob_size = 1024 * 1024; + pool.options.compression_required_ratio = 0.8; + pool.flags_names = 'someFlag1,someFlag2'; + pool.application_metadata = ['rbd', 'rgw']; + createCrushRule({ name: 'someRule' }); + spyOn(poolService, 'get').and.callFake(() => of(pool)); + }); + + it('is not in edit mode if edit is not included in url', () => { + setUrl('/pool/add'); + expect(component.editing).toBeFalsy(); + }); + + it('is in edit mode if edit is included in url', () => { + setUrl('/pool/edit/somePoolName'); + expect(component.editing).toBeTruthy(); + }); + + describe('after ngOnInit', () => { + beforeEach(() => { + component.editing = true; + component.ngOnInit(); + }); + + it('disabled inputs', () => { + const disabled = [ + 'name', + 'poolType', + 'crushRule', + 'size', + 'erasureProfile', + 'ecOverwrites' + ]; + disabled.forEach((controlName) => { + return expect(form.get(controlName).disabled).toBeTruthy(); + }); + const enabled = ['pgNum', 'mode', 'algorithm', 'minBlobSize', 'maxBlobSize', 'ratio']; + enabled.forEach((controlName) => { + return expect(form.get(controlName).enabled).toBeTruthy(); + }); + }); + + it('set all control values to the given pool', () => { + expect(form.getValue('name')).toBe(pool.pool_name); + expect(form.getValue('poolType')).toBe(pool.type); + expect(form.getValue('crushRule')).toEqual(component.info.crush_rules_replicated[0]); + expect(form.getValue('size')).toBe(pool.size); + expect(form.getValue('pgNum')).toBe(pool.pg_num); + expect(form.getValue('mode')).toBe(pool.options.compression_mode); + expect(form.getValue('algorithm')).toBe(pool.options.compression_algorithm); + expect(form.getValue('minBlobSize')).toBe('512 KiB'); + expect(form.getValue('maxBlobSize')).toBe('1 MiB'); + expect(form.getValue('ratio')).toBe(pool.options.compression_required_ratio); + }); + + it('is only be possible to use the same or more pgs like before', () => { + isValid(setPgNum(64)); + hasError(setPgNum(4), 'noDecrease'); + }); + + it(`always provides the application metadata array with submit even if it's empty`, () => { + component.data.applications.selected = []; + testSubmit( + { + application_metadata: [], + compression_algorithm: 'lz4', + compression_max_blob_size: 1048576, + compression_min_blob_size: 524288, + compression_mode: 'passive', + compression_required_ratio: 0.8, + pg_num: 32, + pool: 'somePoolName' + }, + 'pool/edit', + 'update' + ); + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts new file mode 100644 index 00000000000..fc8002c04e5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts @@ -0,0 +1,530 @@ +import { Component, OnInit } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import * as _ from 'lodash'; +import { forkJoin } from 'rxjs'; + +import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service'; +import { PoolService } from '../../../shared/api/pool.service'; +import { CdFormGroup } from '../../../shared/forms/cd-form-group'; +import { CdValidators } from '../../../shared/forms/cd-validators'; +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'; +import { Permission } from '../../../shared/models/permissions'; +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 { Pool } from '../pool'; +import { PoolFormData } from './pool-form-data'; +import { PoolFormInfo } from './pool-form-info'; + +@Component({ + selector: 'cd-pool-form', + templateUrl: './pool-form.component.html', + styleUrls: ['./pool-form.component.scss'] +}) +export class PoolFormComponent implements OnInit { + permission: Permission; + form: CdFormGroup; + compressionForm: CdFormGroup; + ecProfiles: ErasureCodeProfile[]; + info: PoolFormInfo; + routeParamsSubscribe: any; + editing = false; + data = new PoolFormData(); + externalPgChange = false; + current = { + rules: [] + }; + + constructor( + private dimlessBinaryPipe: DimlessBinaryPipe, + private route: ActivatedRoute, + private router: Router, + private poolService: PoolService, + private authStorageService: AuthStorageService, + private formatter: FormatterService, + private taskWrapper: TaskWrapperService, + private ecpService: ErasureCodeProfileService + ) { + this.editing = this.router.url.startsWith('/pool/edit'); + this.authenticate(); + this.createForms(); + } + + authenticate() { + this.permission = this.authStorageService.getPermissions().pool; + if ( + !this.permission.read || + ((!this.permission.update && this.editing) || (!this.permission.create && !this.editing)) + ) { + this.router.navigate(['/404']); + } + } + + private createForms() { + this.compressionForm = new CdFormGroup({ + mode: new FormControl('none'), + algorithm: new FormControl(''), + minBlobSize: new FormControl('', { + updateOn: 'blur' + }), + maxBlobSize: new FormControl('', { + updateOn: 'blur' + }), + ratio: new FormControl('', { + updateOn: 'blur' + }) + }); + this.form = new CdFormGroup( + { + name: new FormControl('', { + validators: [ + Validators.pattern('[A-Za-z0-9_-]+'), + Validators.required, + CdValidators.custom( + 'uniqueName', + (value) => this.info && this.info.pool_names.indexOf(value) !== -1 + ) + ] + }), + poolType: new FormControl('', { + validators: [Validators.required] + }), + crushRule: new FormControl(null, { + validators: [ + CdValidators.custom( + 'tooFewOsds', + (rule) => this.info && rule && this.info.osd_count < rule.min_size + ) + ] + }), + size: new FormControl('', { + updateOn: 'blur' + }), + erasureProfile: new FormControl(null), + pgNum: new FormControl('', { + validators: [Validators.required, Validators.min(1)] + }), + ecOverwrites: new FormControl(false), + compression: this.compressionForm + }, + CdValidators.custom('form', () => null) + ); + } + + ngOnInit() { + forkJoin(this.poolService.getInfo(), this.ecpService.list()).subscribe( + (data: [PoolFormInfo, ErasureCodeProfile[]]) => { + this.initInfo(data[0]); + this.initEcp(data[1]); + if (this.editing) { + this.initEditMode(); + } + this.listenToChanges(); + this.setComplexValidators(); + } + ); + } + + private initInfo(info: PoolFormInfo) { + info.compression_algorithms = info.compression_algorithms.filter((m) => m.length > 0); + this.info = info; + } + + private initEcp(ecProfiles: ErasureCodeProfile[]) { + if (ecProfiles.length === 1) { + const control = this.form.get('erasureProfile'); + control.setValue(ecProfiles[0]); + control.disable(); + } + this.ecProfiles = ecProfiles; + } + + private initEditMode() { + this.disableForEdit(); + this.routeParamsSubscribe = this.route.params.subscribe((param: { name: string }) => + this.poolService.get(param.name).subscribe((pool: Pool) => { + this.data.pool = pool; + this.initEditFormData(pool); + }) + ); + } + + private disableForEdit() { + ['name', 'poolType', 'crushRule', 'size', 'erasureProfile', 'ecOverwrites'].forEach( + (controlName) => this.form.get(controlName).disable() + ); + } + + private initEditFormData(pool: Pool) { + const transform = { + name: 'pool_name', + poolType: 'type', + crushRule: (p) => + this.info['crush_rules_' + p.type].find( + (rule: CrushRule) => rule.rule_name === p.crush_rule + ), + size: 'size', + erasureProfile: (p) => this.ecProfiles.find((ecp) => ecp.name === p.erasure_code_profile), + pgNum: 'pg_num', + ecOverwrites: (p) => p.flags_names.includes('ec_overwrites'), + mode: 'options.compression_mode', + algorithm: 'options.compression_algorithm', + minBlobSize: (p) => this.dimlessBinaryPipe.transform(p.options.compression_min_blob_size), + maxBlobSize: (p) => this.dimlessBinaryPipe.transform(p.options.compression_max_blob_size), + ratio: 'options.compression_required_ratio' + }; + Object.keys(transform).forEach((key) => { + const attrib = transform[key]; + const value = _.isFunction(attrib) ? attrib(pool) : _.get(pool, attrib); + if (!_.isUndefined(value) && value !== '') { + this.form.silentSet(key, value); + } + }); + this.data.applications.selected = pool.application_metadata; + } + + private listenToChanges() { + this.listenToChangesDuringAddEdit(); + if (!this.editing) { + this.listenToChangesDuringAdd(); + } + } + + private listenToChangesDuringAddEdit() { + this.form.get('pgNum').valueChanges.subscribe((pgs) => { + const change = pgs - this.data.pgs; + if (Math.abs(change) === 1) { + this.pgUpdate(undefined, change); + } + }); + } + + private listenToChangesDuringAdd() { + this.form.get('poolType').valueChanges.subscribe((poolType) => { + this.form.get('size').updateValueAndValidity(); + this.rulesChange(); + if (poolType === 'replicated') { + this.replicatedRuleChange(); + } + this.pgCalc(); + }); + this.form.get('crushRule').valueChanges.subscribe(() => { + if (this.form.getValue('poolType') === 'replicated') { + this.replicatedRuleChange(); + } + this.pgCalc(); + }); + this.form.get('size').valueChanges.subscribe(() => { + this.pgCalc(); + }); + this.form.get('erasureProfile').valueChanges.subscribe(() => { + this.pgCalc(); + }); + this.form.get('mode').valueChanges.subscribe(() => { + ['minBlobSize', 'maxBlobSize', 'ratio'].forEach((name) => + this.form.get(name).updateValueAndValidity() + ); + }); + this.form.get('minBlobSize').valueChanges.subscribe(() => { + this.form.get('maxBlobSize').updateValueAndValidity({ emitEvent: false }); + }); + this.form.get('maxBlobSize').valueChanges.subscribe(() => { + this.form.get('minBlobSize').updateValueAndValidity({ emitEvent: false }); + }); + } + + private rulesChange() { + const poolType = this.form.getValue('poolType'); + if (!poolType || !this.info) { + this.current.rules = []; + return; + } + const rules = this.info['crush_rules_' + poolType] || []; + const control = this.form.get('crushRule'); + if (rules.length === 1) { + control.setValue(rules[0]); + control.disable(); + } else { + control.setValue(null); + control.enable(); + } + this.current.rules = rules; + } + + private replicatedRuleChange() { + if (this.form.getValue('poolType') !== 'replicated') { + return; + } + const control = this.form.get('size'); + let size = this.form.getValue('size') || 3; + const min = this.getMinSize(); + const max = this.getMaxSize(); + if (size < min) { + size = min; + } else if (size > max) { + size = max; + } + if (size !== control.value) { + this.form.silentSet('size', size); + } + } + + getMinSize(): number { + if (!this.info || this.info.osd_count < 1) { + return; + } + const rule = this.form.getValue('crushRule'); + if (rule) { + return rule.min_size; + } + return 1; + } + + getMaxSize(): number { + if (!this.info || this.info.osd_count < 1) { + return; + } + const osds: number = this.info.osd_count; + if (this.form.getValue('crushRule')) { + const max: number = this.form.get('crushRule').value.max_size; + if (max < osds) { + return max; + } + } + return osds; + } + + private pgCalc() { + const poolType = this.form.getValue('poolType'); + if (!this.info || this.form.get('pgNum').dirty || !poolType) { + return; + } + const pgMax = this.info.osd_count * 100; + const pgs = + poolType === 'replicated' ? this.replicatedPgCalc(pgMax) : this.erasurePgCalc(pgMax); + if (!pgs) { + return; + } + const oldValue = this.data.pgs; + this.pgUpdate(pgs); + const newValue = this.data.pgs; + if (!this.externalPgChange) { + this.externalPgChange = oldValue !== newValue; + } + } + + private replicatedPgCalc(pgs): number { + const sizeControl = this.form.get('size'); + const size = sizeControl.value; + if (sizeControl.valid && size > 0) { + return pgs / size; + } + } + + private erasurePgCalc(pgs): number { + const ecpControl = this.form.get('erasureProfile'); + const ecp = ecpControl.value; + if ((ecpControl.valid || ecpControl.disabled) && ecp) { + return pgs / (ecp.k + ecp.m); + } + } + + private pgUpdate(pgs?, jump?) { + pgs = _.isNumber(pgs) ? pgs : this.form.getValue('pgNum'); + if (pgs < 1) { + pgs = 1; + } + let power = Math.round(Math.log(pgs) / Math.log(2)); + if (_.isNumber(jump)) { + power += jump; + } + if (power < 0) { + power = 0; + } + pgs = Math.pow(2, power); // Set size the nearest accurate size. + this.data.pgs = pgs; + this.form.silentSet('pgNum', pgs); + } + + private setComplexValidators() { + if (this.editing) { + this.form + .get('pgNum') + .setValidators( + CdValidators.custom('noDecrease', (pgs) => this.data.pool && pgs < this.data.pool.pg_num) + ); + } else { + CdValidators.validateIf( + this.form.get('size'), + () => this.form.get('poolType').value === 'replicated', + [ + CdValidators.custom( + 'min', + (value) => this.form.getValue('size') && value < this.getMinSize() + ), + CdValidators.custom( + 'max', + (value) => this.form.getValue('size') && this.getMaxSize() < value + ) + ] + ); + } + this.setCompressionValidators(); + } + + private setCompressionValidators() { + CdValidators.validateIf(this.form.get('minBlobSize'), () => this.activatedCompression(), [ + Validators.min(0), + CdValidators.custom('maximum', (size) => + this.compareBlobSize(size, this.form.getValue('maxBlobSize')) + ) + ]); + CdValidators.validateIf(this.form.get('maxBlobSize'), () => this.activatedCompression(), [ + Validators.min(0), + CdValidators.custom('minimum', (size) => + this.compareBlobSize(this.form.getValue('minBlobSize'), size) + ) + ]); + CdValidators.validateIf(this.form.get('ratio'), () => this.activatedCompression(), [ + Validators.min(0), + Validators.max(1) + ]); + } + + private compareBlobSize(minimum, maximum) { + return Boolean( + minimum && maximum && this.formatter.toBytes(minimum) >= this.formatter.toBytes(maximum) + ); + } + + activatedCompression() { + return this.form.getValue('mode') && this.form.get('mode').value.toLowerCase() !== 'none'; + } + + pgKeyUp($e) { + const key = $e.key; + const included = (arr: string[]): number => (arr.indexOf(key) !== -1 ? 1 : 0); + const jump = included(['ArrowUp', '+']) - included(['ArrowDown', '-']); + if (jump) { + this.pgUpdate(undefined, jump); + } + } + + describeCrushStep(step: CrushStep) { + return [ + step.op.replace('_', ' '), + step.item_name || '', + step.type ? step.num + ' type ' + step.type : '' + ].join(' '); + } + + submit() { + if (this.form.invalid) { + this.form.setErrors({ cdSubmitButton: true }); + return; + } + const pool = {}; + this.extendByItemsForSubmit(pool, [ + { api: 'pool', name: 'name', edit: true }, + { api: 'pool_type', name: 'poolType' }, + { api: 'pg_num', name: 'pgNum', edit: true }, + this.form.getValue('poolType') === 'replicated' + ? { api: 'size', name: 'size' } + : { api: 'erasure_code_profile', name: 'erasureProfile', attr: 'name' }, + { api: 'rule_name', name: 'crushRule', attr: 'rule_name' } + ]); + if (this.info.is_all_bluestore) { + this.extendByItemForSubmit(pool, { + api: 'flags', + name: 'ecOverwrites', + fn: () => ['ec_overwrites'] + }); + if (this.form.getValue('mode')) { + this.extendByItemsForSubmit(pool, [ + { + api: 'compression_mode', + name: 'mode', + edit: true, + fn: (value) => this.activatedCompression() && value + }, + { api: 'compression_algorithm', name: 'algorithm', edit: true }, + { + api: 'compression_min_blob_size', + name: 'minBlobSize', + fn: this.formatter.toBytes, + edit: true + }, + { + api: 'compression_max_blob_size', + name: 'maxBlobSize', + fn: this.formatter.toBytes, + edit: true + }, + { api: 'compression_required_ratio', name: 'ratio', edit: true } + ]); + } + } + const apps = this.data.applications.selected; + if (apps.length > 0 || this.editing) { + pool['application_metadata'] = apps; + } + this.triggerApiTask(pool); + } + + private extendByItemsForSubmit(pool, items: any[]) { + items.forEach((item) => this.extendByItemForSubmit(pool, item)); + } + + private extendByItemForSubmit( + pool, + { + api, + name, + attr, + fn, + edit + }: { + api: string; + name: string; + attr?: string; + fn?: Function; + edit?: boolean; + } + ) { + if (this.editing && !edit) { + return; + } + const value = this.form.getValue(name); + const apiValue = fn ? fn(value) : attr ? _.get(value, attr) : value; + if (!value || !apiValue) { + return; + } + pool[api] = apiValue; + } + + private triggerApiTask(pool) { + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('pool/' + (this.editing ? 'edit' : 'create'), { + pool_name: pool.pool + }), + call: this.poolService[this.editing ? 'update' : 'create'](pool) + }) + .subscribe( + undefined, + (resp) => { + if (_.isObject(resp.error) && resp.error.code === '34') { + this.form.get('pgNum').setErrors({ '34': true }); + } + this.form.setErrors({ cdSubmitButton: true }); + }, + () => this.router.navigate(['/pool']) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html index 1f3f5f2d284..8037c174809 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html @@ -5,13 +5,55 @@ [columns]="columns" selectionType="single" (updateSelection)="updateSelection($event)"> - +
+
+ + + + +
+
+ - + { let service: PoolService; let httpTesting: HttpTestingController; + const apiPath = 'api/pool'; configureTestBed({ providers: [PoolService], @@ -28,16 +29,37 @@ describe('PoolService', () => { it('should call getList', () => { service.getList().subscribe(); - const req = httpTesting.expectOne('api/pool'); + const req = httpTesting.expectOne(apiPath); 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'); + }); + + it('should call create', () => { + const pool = { pool: 'somePool' }; + service.create(pool).subscribe(); + const req = httpTesting.expectOne(apiPath); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(pool); + }); + + it('should call update', () => { + service.update({ pool: 'somePool', application_metadata: [] }).subscribe(); + const req = httpTesting.expectOne(`${apiPath}/somePool`); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ application_metadata: [] }); + }); + it( 'should call list without parameter', fakeAsync(() => { let result; service.list().then((resp) => (result = resp)); - const req = httpTesting.expectOne('api/pool?attrs='); + const req = httpTesting.expectOne(`${apiPath}?attrs=`); expect(req.request.method).toBe('GET'); req.flush(['foo', 'bar']); tick(); @@ -50,7 +72,7 @@ describe('PoolService', () => { fakeAsync(() => { let result; service.list(['foo']).then((resp) => (result = resp)); - const req = httpTesting.expectOne('api/pool?attrs=foo'); + const req = httpTesting.expectOne(`${apiPath}?attrs=foo`); expect(req.request.method).toBe('GET'); req.flush(['foo', 'bar']); tick(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts index 32cc67bdb4c..2716a889486 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts @@ -9,16 +9,36 @@ import { ApiModule } from './api.module'; providedIn: ApiModule }) export class PoolService { + apiPath = 'api/pool'; + constructor(private http: HttpClient) {} + create(pool) { + return this.http.post(this.apiPath, pool, { observe: 'response' }); + } + + update(pool) { + const name = pool.pool; + delete pool.pool; + return this.http.put(`${this.apiPath}/${name}`, pool, { observe: 'response' }); + } + + get(poolName) { + return this.http.get(`${this.apiPath}/${poolName}`); + } + getList() { - return this.http.get('api/pool'); + return this.http.get(this.apiPath); + } + + getInfo() { + return this.http.get(`${this.apiPath}/_info`); } list(attrs = []) { const attrsStr = attrs.join(','); return this.http - .get(`api/pool?attrs=${attrsStr}`) + .get(`${this.apiPath}?attrs=${attrsStr}`) .toPromise() .then((resp: any) => { return resp; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.spec.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.ts new file mode 100644 index 00000000000..e69de29bb2d -- 2.39.5