File

src/app/ceph/pool/pool-form/pool-form.component.ts

Index

Properties

Properties

attr
attr: string
Type : string
Optional
editable
editable: boolean
Type : boolean
Optional
externalFieldName
externalFieldName: string
Type : string
formControlName
formControlName: string
Type : string
replaceFn
replaceFn: Function
Type : Function
Optional
resetValue
resetValue: any
Type : any
Optional
import { Component, EventEmitter, OnInit } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';

import { I18n } from '@ngx-translate/i18n-polyfill';
import * as _ from 'lodash';
import { BsModalService } from 'ngx-bootstrap/modal';
import { forkJoin, Subscription } from 'rxjs';

import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
import { PoolService } from '../../../shared/api/pool.service';
import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
import { ActionLabelsI18n, URLVerbs } from '../../../shared/constants/app.constants';
import { CdFormGroup } from '../../../shared/forms/cd-form-group';
import { CdValidators } from '../../../shared/forms/cd-validators';
import {
  RbdConfigurationEntry,
  RbdConfigurationSourceField
} from '../../../shared/models/configuration';
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 { PoolFormInfo } from '../../../shared/models/pool-form-info';
import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
import { AuthStorageService } from '../../../shared/services/auth-storage.service';
import { FormatterService } from '../../../shared/services/formatter.service';
import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
import { ErasureCodeProfileFormComponent } from '../erasure-code-profile-form/erasure-code-profile-form.component';
import { Pool } from '../pool';
import { PoolFormData } from './pool-form-data';

interface FormFieldDescription {
  externalFieldName: string;
  formControlName: string;
  attr?: string;
  replaceFn?: Function;
  editable?: boolean;
  resetValue?: any;
}

@Component({
  selector: 'cd-pool-form',
  templateUrl: './pool-form.component.html',
  styleUrls: ['./pool-form.component.scss']
})
export class PoolFormComponent implements OnInit {
  permission: Permission;
  form: CdFormGroup;
  ecProfiles: ErasureCodeProfile[];
  info: PoolFormInfo;
  routeParamsSubscribe: any;
  editing = false;
  data = new PoolFormData(this.i18n);
  externalPgChange = false;
  private modalSubscription: Subscription;
  current = {
    rules: []
  };
  initializeConfigData = new EventEmitter<{
    initialData: RbdConfigurationEntry[];
    sourceType: RbdConfigurationSourceField;
  }>();
  currentConfigurationValues: { [configKey: string]: any } = {};
  action: string;
  resource: string;

  constructor(
    private dimlessBinaryPipe: DimlessBinaryPipe,
    private route: ActivatedRoute,
    private router: Router,
    private modalService: BsModalService,
    private poolService: PoolService,
    private authStorageService: AuthStorageService,
    private formatter: FormatterService,
    private bsModalService: BsModalService,
    private taskWrapper: TaskWrapperService,
    private ecpService: ErasureCodeProfileService,
    private i18n: I18n,
    public actionLabels: ActionLabelsI18n
  ) {
    this.editing = this.router.url.startsWith(`/pool/${URLVerbs.EDIT}`);
    this.action = this.editing ? this.actionLabels.EDIT : this.actionLabels.CREATE;
    this.resource = this.i18n('pool');
    this.authenticate();
    this.createForm();
  }

  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 createForm() {
    const 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]
        }),
        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: compressionForm
      },
      [
        CdValidators.custom('form', () => null),
        CdValidators.custom('rbdPool', () => {
          return (
            this.form &&
            this.form.getValue('name').includes('/') &&
            this.data &&
            this.data.applications.selected.indexOf('rbd') !== -1
          );
        })
      ]
    );
  }

  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) {
    this.form.silentSet('algorithm', info.bluestore_compression_algorithm);
    info.compression_modes.push('unset');
    this.info = info;
  }

  private initEcp(ecProfiles: ErasureCodeProfile[]) {
    const control = this.form.get('erasureProfile');
    if (ecProfiles.length <= 1) {
      control.disable();
    }
    if (ecProfiles.length === 1) {
      control.setValue(ecProfiles[0]);
    } else if (ecProfiles.length > 1 && control.disabled) {
      control.enable();
    }
    this.ecProfiles = ecProfiles;
  }

  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() {
    ['poolType', 'crushRule', 'size', 'erasureProfile', 'ecOverwrites'].forEach((controlName) =>
      this.form.get(controlName).disable()
    );
  }

  private initEditFormData(pool: Pool) {
    this.initializeConfigData.emit({
      initialData: pool.configuration,
      sourceType: RbdConfigurationSourceField.pool
    });

    const dataMap = {
      name: pool.pool_name,
      poolType: pool.type,
      crushRule: this.info['crush_rules_' + pool.type].find(
        (rule: CrushRule) => rule.rule_name === pool.crush_rule
      ),
      size: pool.size,
      erasureProfile: this.ecProfiles.find((ecp) => ecp.name === pool.erasure_code_profile),
      pgNum: pool.pg_num,
      ecOverwrites: pool.flags_names.includes('ec_overwrites'),
      mode: pool.options.compression_mode,
      algorithm: pool.options.compression_algorithm,
      minBlobSize: this.dimlessBinaryPipe.transform(pool.options.compression_min_blob_size),
      maxBlobSize: this.dimlessBinaryPipe.transform(pool.options.compression_max_blob_size),
      ratio: pool.options.compression_required_ratio
    };

    Object.keys(dataMap).forEach((controlName: string) => {
      const value = dataMap[controlName];
      if (!_.isUndefined(value) && value !== '') {
        this.form.silentSet(controlName, 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 || pgs === 2) {
        this.data.pgs = pgs;
        return;
      }
      this.doPgPowerJump(change as 1 | -1);
    });
  }

  private doPgPowerJump(jump: 1 | -1) {
    const power = this.calculatePgPower() + jump;
    this.setPgs(jump === -1 ? Math.round(power) : Math.floor(power));
  }

  private calculatePgPower(pgs = this.form.getValue('pgNum')): number {
    return Math.log(pgs) / Math.log(2);
  }

  private setPgs(power: number) {
    const pgs = Math.pow(2, power < 0 ? 0 : power); // Set size the nearest accurate size.
    this.data.pgs = pgs;
    this.form.silentSet('pgNum', pgs);
  }

  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({ emitEvent: false });
      });
    });
    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.alignPgs(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 alignPgs(pgs = this.form.getValue('pgNum')) {
    this.setPgs(Math.round(this.calculatePgPower(pgs < 1 ? 1 : pgs)));
  }

  private setComplexValidators() {
    if (this.editing) {
      this.form
        .get('pgNum')
        .setValidators(
          CdValidators.custom('noDecrease', (pgs) => this.data.pool && pgs < this.data.pool.pg_num)
        );
      this.form
        .get('name')
        .setValidators([
          this.form.get('name').validator,
          CdValidators.custom(
            'uniqueName',
            (name) =>
              this.data.pool &&
              this.info &&
              this.info.pool_names.indexOf(name) !== -1 &&
              this.info.pool_names.indexOf(name) !==
                this.info.pool_names.indexOf(this.data.pool.pool_name)
          )
        ]);
    } 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.form
        .get('name')
        .setValidators([
          this.form.get('name').validator,
          CdValidators.custom(
            'uniqueName',
            (name) => this.info && this.info.pool_names.indexOf(name) !== -1
          )
        ]);
    }
    this.setCompressionValidators();
  }

  private setCompressionValidators() {
    CdValidators.validateIf(this.form.get('minBlobSize'), () => this.hasCompressionEnabled(), [
      Validators.min(0),
      CdValidators.custom('maximum', (size) =>
        this.oddBlobSize(size, this.form.getValue('maxBlobSize'))
      )
    ]);
    CdValidators.validateIf(this.form.get('maxBlobSize'), () => this.hasCompressionEnabled(), [
      Validators.min(0),
      CdValidators.custom('minimum', (size) =>
        this.oddBlobSize(this.form.getValue('minBlobSize'), size)
      )
    ]);
    CdValidators.validateIf(this.form.get('ratio'), () => this.hasCompressionEnabled(), [
      Validators.min(0),
      Validators.max(1)
    ]);
  }

  private oddBlobSize(minimum, maximum) {
    minimum = this.formatter.toBytes(minimum);
    maximum = this.formatter.toBytes(maximum);
    return Boolean(minimum && maximum && minimum >= maximum);
  }

  hasCompressionEnabled() {
    return this.form.getValue('mode') && this.form.get('mode').value.toLowerCase() !== 'none';
  }

  describeCrushStep(step: CrushStep) {
    return [
      step.op.replace('_', ' '),
      step.item_name || '',
      step.type ? step.num + ' type ' + step.type : ''
    ].join(' ');
  }

  addErasureCodeProfile() {
    this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadECPs());
    this.bsModalService.show(ErasureCodeProfileFormComponent);
  }

  private reloadECPs() {
    this.ecpService.list().subscribe((profiles: ErasureCodeProfile[]) => this.initEcp(profiles));
    this.modalSubscription.unsubscribe();
  }

  deleteErasureCodeProfile() {
    const ecp = this.form.getValue('erasureProfile');
    if (!ecp) {
      return;
    }
    const name = ecp.name;
    this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadECPs());
    this.modalService.show(CriticalConfirmationModalComponent, {
      initialState: {
        itemDescription: this.i18n('erasure code profile'),
        submitActionObservable: () =>
          this.taskWrapper.wrapTaskAroundCall({
            task: new FinishedTask('ecp/delete', { name: name }),
            call: this.ecpService.delete(name)
          })
      }
    });
  }

  submit() {
    if (this.form.invalid) {
      this.form.setErrors({ cdSubmitButton: true });
      return;
    }

    const pool = { pool: this.form.getValue('name') };

    this.assignFormFields(pool, [
      { externalFieldName: 'pool_type', formControlName: 'poolType' },
      { externalFieldName: 'pg_num', formControlName: 'pgNum', editable: true },
      this.form.getValue('poolType') === 'replicated'
        ? { externalFieldName: 'size', formControlName: 'size' }
        : {
            externalFieldName: 'erasure_code_profile',
            formControlName: 'erasureProfile',
            attr: 'name'
          },
      { externalFieldName: 'rule_name', formControlName: 'crushRule', attr: 'rule_name' }
    ]);

    if (this.info.is_all_bluestore) {
      this.assignFormField(pool, {
        externalFieldName: 'flags',
        formControlName: 'ecOverwrites',
        replaceFn: () => ['ec_overwrites']
      });

      if (this.form.getValue('mode') !== 'none') {
        this.assignFormFields(pool, [
          {
            externalFieldName: 'compression_mode',
            formControlName: 'mode',
            editable: true,
            replaceFn: (value) => this.hasCompressionEnabled() && value
          },
          {
            externalFieldName: 'compression_algorithm',
            formControlName: 'algorithm',
            editable: true
          },
          {
            externalFieldName: 'compression_min_blob_size',
            formControlName: 'minBlobSize',
            replaceFn: this.formatter.toBytes,
            editable: true,
            resetValue: 0
          },
          {
            externalFieldName: 'compression_max_blob_size',
            formControlName: 'maxBlobSize',
            replaceFn: this.formatter.toBytes,
            editable: true,
            resetValue: 0
          },
          {
            externalFieldName: 'compression_required_ratio',
            formControlName: 'ratio',
            editable: true,
            resetValue: 0
          }
        ]);
      } else if (this.editing) {
        this.assignFormFields(pool, [
          {
            externalFieldName: 'compression_mode',
            formControlName: 'mode',
            editable: true,
            replaceFn: () => 'unset'
          },
          {
            externalFieldName: 'srcpool',
            formControlName: 'name',
            editable: true,
            replaceFn: () => this.data.pool.pool_name
          }
        ]);
      }
    }

    const apps = this.data.applications.selected;
    if (apps.length > 0 || this.editing) {
      pool['application_metadata'] = apps;
    }

    // Only collect configuration data for replicated pools, as QoS cannot be configured on EC
    // pools. EC data pools inherit their settings from the corresponding replicated metadata pool.
    if (
      this.form.get('poolType').value === 'replicated' &&
      !_.isEmpty(this.currentConfigurationValues)
    ) {
      pool['configuration'] = this.currentConfigurationValues;
    }

    this.triggerApiTask(pool);
  }

  /**
   * Retrieves the values for the given form field descriptions and assigns the values to the given
   * object. This method differentiates between `add` and `edit` mode and acts differently on one or
   * the other.
   */
  private assignFormFields(pool: object, formFieldDescription: FormFieldDescription[]): void {
    formFieldDescription.forEach((item) => this.assignFormField(pool, item));
  }

  /**
   * Retrieves the value for the given form field description and assigns the values to the given
   * object. This method differentiates between `add` and `edit` mode and acts differently on one or
   * the other.
   */
  private assignFormField(
    pool: object,
    {
      externalFieldName,
      formControlName,
      attr,
      replaceFn,
      editable,
      resetValue
    }: FormFieldDescription
  ): void {
    if (this.editing && (!editable || this.form.get(formControlName).pristine)) {
      return;
    }
    const value = this.form.getValue(formControlName);
    let apiValue = replaceFn ? replaceFn(value) : attr ? _.get(value, attr) : value;
    if (!value || !apiValue) {
      if (editable && !_.isUndefined(resetValue)) {
        apiValue = resetValue;
      } else {
        return;
      }
    }
    pool[externalFieldName] = apiValue;
  }

  private triggerApiTask(pool) {
    this.taskWrapper
      .wrapTaskAroundCall({
        task: new FinishedTask('pool/' + (this.editing ? URLVerbs.EDIT : URLVerbs.CREATE), {
          pool_name: pool.hasOwnProperty('srcpool') ? pool.srcpool : pool.pool
        }),
        call: this.poolService[this.editing ? URLVerbs.UPDATE : URLVerbs.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'])
      );
  }

  appSelection() {
    this.form.updateValueAndValidity({ emitEvent: false, onlySelf: true });
  }
}

result-matching ""

    No results matching ""