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';
// 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
{
--- /dev/null
+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
+}
--- /dev/null
+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[];
+}
--- /dev/null
+<div class="col-sm-12 col-lg-6">
+ <h1 *ngIf="!(info && ecProfiles)"
+ i18n
+ class="jumbotron">
+ <i class="fa fa-lg fa-pulse fa-spinner text-primary"></i>
+ Loading...
+ </h1>
+ <form name="form"
+ *ngIf="info && ecProfiles"
+ class="form-horizontal"
+ #formDir="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ <h3 class="panel-title">
+ <span i18n>{{ editing ? 'Edit' : 'Add' }} pool</span>
+ </h3>
+ </div>
+
+ <div class="panel-body">
+ <!-- Name -->
+ <div class="form-group"
+ [ngClass]="{'has-error': form.showError('name', formDir)}">
+ <label i18n
+ class="control-label col-sm-3"
+ for="name">
+ Name
+ <span class="required"></span>
+ </label>
+ <div class="col-sm-9">
+ <input id="name"
+ name="name"
+ type="text"
+ class="form-control"
+ placeholder="Name..."
+ i18n-placeholder
+ formControlName="name"
+ autofocus>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('name', formDir, 'required')">
+ This field is required!
+ </span>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('name', formDir, 'uniqueName')">
+ The chosen Ceph pool name is already in use.
+ </span>
+ </div>
+ </div>
+
+ <!-- Pool type selection -->
+ <div class="form-group"
+ [ngClass]="{'has-error': form.showError('poolType', formDir)}">
+ <label i18n
+ class="control-label col-sm-3"
+ for="poolType">
+ Pool type
+ <span class="required"></span>
+ </label>
+ <div class="col-sm-9">
+ <select class="form-control"
+ id="poolType"
+ formControlName="poolType"
+ name="poolType">
+ <option ngValue=""
+ i18n>
+ -- Select a pool type --
+ </option>
+ <option *ngFor="let poolType of data.poolTypes"
+ [value]="poolType">
+ {{ poolType }}
+ </option>
+ </select>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('poolType', formDir, 'required')">
+ This field is required!
+ </span>
+ </div>
+ </div>
+
+ <div *ngIf="form.getValue('poolType')">
+ <!-- Pg number -->
+ <div class="form-group"
+ [ngClass]="{'has-error': form.showError('pgNum', formDir)}">
+ <label i18n
+ class="control-label col-sm-3"
+ for="pgNum">
+ Placement groups
+ <span class="required"></span>
+ </label>
+ <div class="col-sm-9">
+ <input class="form-control"
+ id="pgNum"
+ name="pgNum"
+ formControlName="pgNum"
+ min="1"
+ type="number"
+ (focus)="externalPgChange = false"
+ (keyup)="pgKeyUp($event)"
+ (blur)="pgUpdate()"
+ required>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('pgNum', formDir, 'required')">
+ This field is required!
+ </span>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('pgNum', formDir, 'min')">
+ At least one placement group is needed!
+ </span>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('pgNum', formDir, '34')">
+ Your cluster can't handle this many PGs. Please recalculate the PG amount needed.
+ </span>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('pgNum', formDir, 'noDecrease')">
+ You can only increase the number of PGs of an existing pool.
+ Currently your pool has {{ data.pool.pg_num }} PGs.
+ </span>
+ <span class="help-block">
+ <a i18n
+ target="_blank"
+ href="http://ceph.com/pgcalc">Calculation help</a>
+ </span>
+ <span class="help-block"
+ i18n
+ *ngIf="externalPgChange">
+ The current PGs settings were calculated for you, you should make sure the values
+ suite your needs before submit.
+ </span>
+ </div>
+ </div>
+
+ <!-- Crush ruleset selection -->
+ <ng-template #crushSteps>
+ <ng-container *ngIf="form.getValue('crushRule')">
+ <div class="crush-rule-steps">
+ <ol>
+ <li *ngFor="let step of form.get('crushRule').value.steps">
+ {{ describeCrushStep(step) }}
+ </li>
+ </ol>
+ </div>
+ </ng-container>
+ </ng-template>
+ <div class="form-group"
+ [ngClass]="{'has-error': form.showError('crushRule', formDir)}"
+ *ngIf="form.getValue('poolType') && current.rules.length > 0">
+ <label class="control-label col-sm-3"
+ for="crushSet"
+ i18n>
+ Crush ruleset
+ </label>
+ <div class="col-sm-9"
+ [popover]="crushSteps"
+ popoverTitle="Steps"
+ triggers="mouseenter:mouseleave">
+ <select class="form-control"
+ id="crushSet"
+ formControlName="crushRule"
+ name="crushSet">
+ <option i18n
+ [ngValue]="null">
+ -- Select a crush rule --
+ </option>
+ <option *ngFor="let rule of current.rules"
+ [ngValue]="rule">
+ {{ rule.rule_name }}
+ </option>
+ </select>
+ <span class="help-block"
+ i18n
+ *ngIf="form.showError('crushRule', formDir, 'tooFewOsds')">
+ 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.
+ </span>
+ </div>
+ </div>
+
+ <!-- Replica Size -->
+ <div class="form-group"
+ [ngClass]="{'has-error': form.showError('size', formDir)}"
+ *ngIf="form.getValue('poolType') === 'replicated'">
+ <label i18n
+ class="control-label col-sm-3"
+ for="size">
+ Replicated size
+ <span class="required"></span>
+ </label>
+ <div class="col-sm-9">
+ <input class="form-control"
+ id="size"
+ [max]="getMaxSize()"
+ [min]="getMinSize()"
+ name="size"
+ type="number"
+ formControlName="size">
+ <span class="help-block"
+ *ngIf="form.showError('size', formDir)">
+ <ul class="list-inline">
+ <li i18n>
+ Minimum: {{ getMinSize() }}
+ </li>
+ <li i18n>
+ Maximum: {{ getMaxSize() }}
+ </li>
+ </ul>
+ </span>
+ <span class="help-block"
+ i18n
+ *ngIf="form.showError('size', formDir)">
+ The size specified is out of range.
+ A value from {{ getMinSize() }} to {{ getMaxSize() }} is valid.
+ </span>
+ </div>
+ </div>
+
+ <!-- Erasure Profile select -->
+ <div class="form-group"
+ *ngIf="form.getValue('poolType') === 'erasure'">
+ <label i18n
+ class="control-label col-sm-3"
+ for="erasureProfile">
+ 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>
+ </div>
+
+ <!-- Flags -->
+ <div class="form-group"
+ *ngIf="info.is_all_bluestore && form.getValue('poolType') === 'erasure'">
+ <label i18n
+ class="control-label col-sm-3">
+ Flags
+ </label>
+ <div class="col-sm-9">
+ <div class="input-group">
+ <div class="checkbox checkbox-primary">
+ <input id="ec-overwrites"
+ type="checkbox"
+ formControlName="ecOverwrites">
+ <label i18n
+ for="ec-overwrites">
+ EC Overwrites
+ </label>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Applications -->
+ <div class="form-group">
+ <label i18n
+ class="col-sm-3 control-label"
+ for="applications">
+ Applications
+ </label>
+ <div class="col-sm-9">
+ <span class="form-control no-border full-height">
+ <cd-select-badges
+ id="applications"
+ [customBadges]="true"
+ [customBadgeValidators]="data.applications.validators"
+ [messages]="data.applications.messages"
+ [data]="data.applications.selected"
+ [options]="data.applications.available"
+ [selectionLimit]="4">
+ </cd-select-badges>
+ </span>
+ </div>
+ </div>
+
+ <!-- Compression -->
+ <div *ngIf="info.is_all_bluestore" formGroupName="compression">
+ <legend i18n>Compression</legend>
+
+ <!-- Compression Mode -->
+ <div class="form-group">
+ <label i18n
+ class="control-label col-sm-3"
+ for="mode">
+ Mode
+ </label>
+ <div class="col-sm-9">
+ <select class="form-control"
+ id="mode"
+ name="mode"
+ formControlName="mode">
+ <option i18n
+ ngValue="">
+ -- Select a compression mode --
+ </option>
+ <option *ngFor="let mode of info.compression_modes"
+ [value]="mode">
+ {{ mode }}
+ </option>
+ </select>
+ </div>
+ </div>
+ <div *ngIf="activatedCompression()">
+ <!-- Compression algorithm selection -->
+ <div class="form-group"
+ [ngClass]="{'has-error': form.showError('algorithm', formDir)}">
+ <label i18n
+ class="control-label col-sm-3"
+ for="algorithm">
+ Algorithm
+ </label>
+ <div class="col-sm-9">
+ <select class="form-control"
+ id="algorithm"
+ name="algorithm"
+ formControlName="algorithm">
+ <option *ngIf="!info.compression_algorithms"
+ ngValue=""
+ i18n>
+ Loading...
+ </option>
+ <option *ngIf="info.compression_algorithms && info.compression_algorithms.length === 0"
+ i18n
+ ngValue="">
+ -- No erasure compression algorithm available --
+ </option>
+ <option *ngIf="info.compression_algorithms &&
+ info.compression_algorithms.length > 0"
+ i18n
+ ngValue="">
+ -- Select a compression algorithm --
+ </option>
+ <option *ngFor="let algorithm of info.compression_algorithms"
+ [value]="algorithm">
+ {{ algorithm }}
+ </option>
+ </select>
+ </div>
+ </div>
+
+ <!-- Compression min blob size -->
+ <div class="form-group"
+ [ngClass]="{'has-error': form.showError('minBlobSize', formDir)}">
+ <label i18n
+ class="control-label col-sm-3"
+ for="minBlobSize">
+ Minimum blob size
+ </label>
+ <div class="col-sm-9">
+ <input id="minBlobSize"
+ name="minBlobSize"
+ formControlName="minBlobSize"
+ type="text"
+ min="0"
+ class="form-control"
+ i18n-placeholder
+ placeholder="e.g., 128KiB"
+ defaultUnit="KiB"
+ cdDimlessBinary>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('minBlobSize', formDir, 'min')">
+ Value should be greater than 0
+ </span>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('minBlobSize', formDir, 'maximum')">
+ Value should be greater than the maximum blob size
+ </span>
+ </div>
+ </div>
+
+ <!-- Compression max blob size -->
+ <div class="form-group"
+ [ngClass]="{'has-error': form.showError('maxBlobSize', formDir)}">
+ <label i18n
+ class="control-label col-sm-3"
+ for="maxBlobSize">
+ Maximum blob size
+ </label>
+ <div class="col-sm-9">
+ <input id="maxBlobSize"
+ type="text"
+ min="0"
+ formControlName="maxBlobSize"
+ class="form-control"
+ i18n-placeholder
+ placeholder="e.g., 512KiB"
+ defaultUnit="KiB"
+ cdDimlessBinary>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('maxBlobSize', formDir, 'min')">
+ Value should be greater than 0
+ </span>
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('maxBlobSize', formDir, 'minimum')">
+ Value should be greater than the minimum blob size
+ </span>
+ </div>
+ </div>
+
+ <!-- Compression ratio -->
+ <div class="form-group"
+ [ngClass]="{'has-error': form.showError('ratio', formDir)}">
+ <label i18n
+ class="control-label col-sm-3"
+ for="ratio">
+ Ratio
+ </label>
+ <div class="col-sm-9">
+ <input id="ratio"
+ name="ratio"
+ formControlName="ratio"
+ type="number"
+ min="0"
+ max="1"
+ step="0.1"
+ class="form-control"
+ i18n-placeholder
+ placeholder="Compression ratio">
+ <span i18n
+ class="help-block"
+ *ngIf="form.showError('ratio', formDir, 'min') || form.showError('ratio', formDir, 'max')">
+ Value should be between 0.0 and 1.0
+ </span>
+ </div>
+ </div>
+
+ </div>
+ </div>
+
+ </div>
+ </div>
+
+ <div class="panel-footer">
+ <div class="button-group text-right">
+ <cd-submit-button [form]="formDir"
+ type="button"
+ (submitAction)="submit()">
+ <span i18n>{{ editing ? 'Edit' : 'Create' }} pool</span>
+ </cd-submit-button>
+ <button i18n
+ type="button"
+ class="btn btn-sm btn-default"
+ routerLink="/pool">
+ Back
+ </button>
+ </div>
+ </div>
+ </div>
+ </form>
+</div>
--- /dev/null
+.crush-rule-steps {
+ margin-top: 10px;
+}
--- /dev/null
+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<PoolFormComponent>;
+ 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'
+ );
+ });
+ });
+ });
+});
--- /dev/null
+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'])
+ );
+ }
+}
[columns]="columns"
selectionType="single"
(updateSelection)="updateSelection($event)">
- <tabset cdTableDetail
- *ngIf="selection.hasSingleSelection">
+ <div class="table-actions">
+ <div class="btn-group"
+ dropdown>
+ <button type="button"
+ class="btn btn-sm btn-primary"
+ *ngIf="!selection.hasSingleSelection"
+ routerLink="/pool/add">
+ <i class="fa fa-fw fa-plus"></i><span i18n>Add</span>
+ </button>
+ <button type="button"
+ class="btn btn-sm btn-primary"
+ *ngIf="selection.hasSingleSelection"
+ [ngClass]="{'disabled': selection.first()?.executing}"
+ routerLink="/pool/edit/{{ selection.first()?.pool_name }}">
+ <i class="fa fa-fw fa-pencil"></i><span i18n>Edit</span>
+ </button>
+ <button type="button"
+ dropdownToggle
+ class="btn btn-sm btn-primary dropdown-toggle dropdown-toggle-split">
+ <span class="caret"></span>
+ <span class="sr-only"></span>
+ </button>
+ <ul *dropdownMenu class="dropdown-menu" role="menu">
+ <li role="menuitem">
+ <a class="dropdown-item"
+ routerLink="/pool/add">
+ <i class="fa fa-fw fa-plus"></i><span i18n>Add</span>
+ </a>
+ </li>
+ <li role="menuitem"
+ [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first()?.executing}">
+ <a class="dropdown-item"
+ routerLink="/pool/edit/{{ selection.first()?.pool_name }}">
+ <i class="fa fa-fw fa-pencil"></i><span i18n>Edit</span></a>
+ </li>
+ <li role="menuitem"
+ [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first()?.executing}">
+ <a class="dropdown-item"
+ (click)="deletePoolModal()">
+ <i class="fa fa-fw fa-trash-o"></i><span i18n>Delete</span>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ <tabset cdTableDetail *ngIf="selection.hasSingleSelection">
<tab i18n-heading
heading="Details">
- <cd-table-key-value [data]="selection.first()"
- [renderObjects]="true"
- [autoReload]="false">
+ <cd-table-key-value [renderObjects]="true" [data]="selection.first()" [autoReload]="false">
</cd-table-key-value>
</tab>
<tab i18n-heading
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
-import { TabsModule } from 'ngx-bootstrap/tabs';
+import { BsDropdownModule, PopoverModule, TabsModule } from 'ngx-bootstrap';
import { ServicesModule } from '../../shared/services/services.module';
import { SharedModule } from '../../shared/shared.module';
+import { PoolFormComponent } from './pool-form/pool-form.component';
import { PoolListComponent } from './pool-list/pool-list.component';
@NgModule({
- imports: [CommonModule, TabsModule, SharedModule, ServicesModule],
- exports: [PoolListComponent],
- declarations: [PoolListComponent]
+ imports: [
+ CommonModule,
+ TabsModule,
+ PopoverModule.forRoot(),
+ SharedModule,
+ RouterModule,
+ ReactiveFormsModule,
+ BsDropdownModule,
+ ServicesModule
+ ],
+ exports: [PoolListComponent, PoolFormComponent],
+ declarations: [PoolListComponent, PoolFormComponent]
})
export class PoolModule {}
describe('PoolService', () => {
let service: PoolService;
let httpTesting: HttpTestingController;
+ const apiPath = 'api/pool';
configureTestBed({
providers: [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();
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();
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;