]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
import new upstream nautilus stable release 14.2.8
[ceph.git] / ceph / src / pybind / mgr / dashboard / frontend / src / app / ceph / pool / pool-form / pool-form.component.ts
1 import { Component, EventEmitter, OnInit } from '@angular/core';
2 import { FormControl, Validators } from '@angular/forms';
3 import { ActivatedRoute, Router } from '@angular/router';
4
5 import { I18n } from '@ngx-translate/i18n-polyfill';
6 import * as _ from 'lodash';
7 import { BsModalService } from 'ngx-bootstrap/modal';
8 import { forkJoin, Subscription } from 'rxjs';
9
10 import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
11 import { PoolService } from '../../../shared/api/pool.service';
12 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
13 import { ActionLabelsI18n, URLVerbs } from '../../../shared/constants/app.constants';
14 import { CdFormGroup } from '../../../shared/forms/cd-form-group';
15 import { CdValidators } from '../../../shared/forms/cd-validators';
16 import {
17 RbdConfigurationEntry,
18 RbdConfigurationSourceField
19 } from '../../../shared/models/configuration';
20 import { CrushRule } from '../../../shared/models/crush-rule';
21 import { CrushStep } from '../../../shared/models/crush-step';
22 import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile';
23 import { FinishedTask } from '../../../shared/models/finished-task';
24 import { Permission } from '../../../shared/models/permissions';
25 import { PoolFormInfo } from '../../../shared/models/pool-form-info';
26 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
27 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
28 import { FormatterService } from '../../../shared/services/formatter.service';
29 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
30 import { ErasureCodeProfileFormComponent } from '../erasure-code-profile-form/erasure-code-profile-form.component';
31 import { Pool } from '../pool';
32 import { PoolFormData } from './pool-form-data';
33
34 interface FormFieldDescription {
35 externalFieldName: string;
36 formControlName: string;
37 attr?: string;
38 replaceFn?: Function;
39 editable?: boolean;
40 resetValue?: any;
41 }
42
43 @Component({
44 selector: 'cd-pool-form',
45 templateUrl: './pool-form.component.html',
46 styleUrls: ['./pool-form.component.scss']
47 })
48 export class PoolFormComponent implements OnInit {
49 permission: Permission;
50 form: CdFormGroup;
51 ecProfiles: ErasureCodeProfile[];
52 info: PoolFormInfo;
53 routeParamsSubscribe: any;
54 editing = false;
55 data = new PoolFormData(this.i18n);
56 externalPgChange = false;
57 private modalSubscription: Subscription;
58 current = {
59 rules: []
60 };
61 initializeConfigData = new EventEmitter<{
62 initialData: RbdConfigurationEntry[];
63 sourceType: RbdConfigurationSourceField;
64 }>();
65 currentConfigurationValues: { [configKey: string]: any } = {};
66 action: string;
67 resource: string;
68
69 constructor(
70 private dimlessBinaryPipe: DimlessBinaryPipe,
71 private route: ActivatedRoute,
72 private router: Router,
73 private modalService: BsModalService,
74 private poolService: PoolService,
75 private authStorageService: AuthStorageService,
76 private formatter: FormatterService,
77 private bsModalService: BsModalService,
78 private taskWrapper: TaskWrapperService,
79 private ecpService: ErasureCodeProfileService,
80 private i18n: I18n,
81 public actionLabels: ActionLabelsI18n
82 ) {
83 this.editing = this.router.url.startsWith(`/pool/${URLVerbs.EDIT}`);
84 this.action = this.editing ? this.actionLabels.EDIT : this.actionLabels.CREATE;
85 this.resource = this.i18n('pool');
86 this.authenticate();
87 this.createForm();
88 }
89
90 authenticate() {
91 this.permission = this.authStorageService.getPermissions().pool;
92 if (
93 !this.permission.read ||
94 ((!this.permission.update && this.editing) || (!this.permission.create && !this.editing))
95 ) {
96 this.router.navigate(['/404']);
97 }
98 }
99
100 private createForm() {
101 const compressionForm = new CdFormGroup({
102 mode: new FormControl('none'),
103 algorithm: new FormControl(''),
104 minBlobSize: new FormControl('', {
105 updateOn: 'blur'
106 }),
107 maxBlobSize: new FormControl('', {
108 updateOn: 'blur'
109 }),
110 ratio: new FormControl('', {
111 updateOn: 'blur'
112 })
113 });
114
115 this.form = new CdFormGroup(
116 {
117 name: new FormControl('', {
118 validators: [Validators.pattern(/^[.A-Za-z0-9_/-]+$/), Validators.required]
119 }),
120 poolType: new FormControl('', {
121 validators: [Validators.required]
122 }),
123 crushRule: new FormControl(null, {
124 validators: [
125 CdValidators.custom(
126 'tooFewOsds',
127 (rule) => this.info && rule && this.info.osd_count < rule.min_size
128 )
129 ]
130 }),
131 size: new FormControl('', {
132 updateOn: 'blur'
133 }),
134 erasureProfile: new FormControl(null),
135 pgNum: new FormControl('', {
136 validators: [Validators.required, Validators.min(1)]
137 }),
138 ecOverwrites: new FormControl(false),
139 compression: compressionForm
140 },
141 [
142 CdValidators.custom('form', () => null),
143 CdValidators.custom('rbdPool', () => {
144 return (
145 this.form &&
146 this.form.getValue('name').includes('/') &&
147 this.data &&
148 this.data.applications.selected.indexOf('rbd') !== -1
149 );
150 })
151 ]
152 );
153 }
154
155 ngOnInit() {
156 forkJoin(this.poolService.getInfo(), this.ecpService.list()).subscribe(
157 (data: [PoolFormInfo, ErasureCodeProfile[]]) => {
158 this.initInfo(data[0]);
159 this.initEcp(data[1]);
160 if (this.editing) {
161 this.initEditMode();
162 }
163 this.listenToChanges();
164 this.setComplexValidators();
165 }
166 );
167 }
168
169 private initInfo(info: PoolFormInfo) {
170 this.form.silentSet('algorithm', info.bluestore_compression_algorithm);
171 this.info = info;
172 }
173
174 private initEcp(ecProfiles: ErasureCodeProfile[]) {
175 const control = this.form.get('erasureProfile');
176 if (ecProfiles.length <= 1) {
177 control.disable();
178 }
179 if (ecProfiles.length === 1) {
180 control.setValue(ecProfiles[0]);
181 } else if (ecProfiles.length > 1 && control.disabled) {
182 control.enable();
183 }
184 this.ecProfiles = ecProfiles;
185 }
186
187 private initEditMode() {
188 this.disableForEdit();
189 this.routeParamsSubscribe = this.route.params.subscribe((param: { name: string }) =>
190 this.poolService.get(param.name).subscribe((pool: Pool) => {
191 this.data.pool = pool;
192 this.initEditFormData(pool);
193 })
194 );
195 }
196
197 private disableForEdit() {
198 ['poolType', 'crushRule', 'size', 'erasureProfile', 'ecOverwrites'].forEach((controlName) =>
199 this.form.get(controlName).disable()
200 );
201 }
202
203 private initEditFormData(pool: Pool) {
204 this.initializeConfigData.emit({
205 initialData: pool.configuration,
206 sourceType: RbdConfigurationSourceField.pool
207 });
208
209 const dataMap = {
210 name: pool.pool_name,
211 poolType: pool.type,
212 crushRule: this.info['crush_rules_' + pool.type].find(
213 (rule: CrushRule) => rule.rule_name === pool.crush_rule
214 ),
215 size: pool.size,
216 erasureProfile: this.ecProfiles.find((ecp) => ecp.name === pool.erasure_code_profile),
217 pgNum: pool.pg_num,
218 ecOverwrites: pool.flags_names.includes('ec_overwrites'),
219 mode: pool.options.compression_mode,
220 algorithm: pool.options.compression_algorithm,
221 minBlobSize: this.dimlessBinaryPipe.transform(pool.options.compression_min_blob_size),
222 maxBlobSize: this.dimlessBinaryPipe.transform(pool.options.compression_max_blob_size),
223 ratio: pool.options.compression_required_ratio
224 };
225
226 Object.keys(dataMap).forEach((controlName: string) => {
227 const value = dataMap[controlName];
228 if (!_.isUndefined(value) && value !== '') {
229 this.form.silentSet(controlName, value);
230 }
231 });
232 this.data.pgs = this.form.getValue('pgNum');
233 this.data.applications.selected = pool.application_metadata;
234 }
235
236 private listenToChanges() {
237 this.listenToChangesDuringAddEdit();
238 if (!this.editing) {
239 this.listenToChangesDuringAdd();
240 }
241 }
242
243 private listenToChangesDuringAddEdit() {
244 this.form.get('pgNum').valueChanges.subscribe((pgs) => {
245 const change = pgs - this.data.pgs;
246 if (Math.abs(change) !== 1 || pgs === 2) {
247 this.data.pgs = pgs;
248 return;
249 }
250 this.doPgPowerJump(change as 1 | -1);
251 });
252 }
253
254 private doPgPowerJump(jump: 1 | -1) {
255 const power = this.calculatePgPower() + jump;
256 this.setPgs(jump === -1 ? Math.round(power) : Math.floor(power));
257 }
258
259 private calculatePgPower(pgs = this.form.getValue('pgNum')): number {
260 return Math.log(pgs) / Math.log(2);
261 }
262
263 private setPgs(power: number) {
264 const pgs = Math.pow(2, power < 0 ? 0 : power); // Set size the nearest accurate size.
265 this.data.pgs = pgs;
266 this.form.silentSet('pgNum', pgs);
267 }
268
269 private listenToChangesDuringAdd() {
270 this.form.get('poolType').valueChanges.subscribe((poolType) => {
271 this.form.get('size').updateValueAndValidity();
272 this.rulesChange();
273 if (poolType === 'replicated') {
274 this.replicatedRuleChange();
275 }
276 this.pgCalc();
277 });
278 this.form.get('crushRule').valueChanges.subscribe(() => {
279 if (this.form.getValue('poolType') === 'replicated') {
280 this.replicatedRuleChange();
281 }
282 this.pgCalc();
283 });
284 this.form.get('size').valueChanges.subscribe(() => {
285 this.pgCalc();
286 });
287 this.form.get('erasureProfile').valueChanges.subscribe(() => {
288 this.pgCalc();
289 });
290 this.form.get('mode').valueChanges.subscribe(() => {
291 ['minBlobSize', 'maxBlobSize', 'ratio'].forEach((name) => {
292 this.form.get(name).updateValueAndValidity({ emitEvent: false });
293 });
294 });
295 this.form.get('minBlobSize').valueChanges.subscribe(() => {
296 this.form.get('maxBlobSize').updateValueAndValidity({ emitEvent: false });
297 });
298 this.form.get('maxBlobSize').valueChanges.subscribe(() => {
299 this.form.get('minBlobSize').updateValueAndValidity({ emitEvent: false });
300 });
301 }
302
303 private rulesChange() {
304 const poolType = this.form.getValue('poolType');
305 if (!poolType || !this.info) {
306 this.current.rules = [];
307 return;
308 }
309 const rules = this.info['crush_rules_' + poolType] || [];
310 const control = this.form.get('crushRule');
311 if (rules.length === 1) {
312 control.setValue(rules[0]);
313 control.disable();
314 } else {
315 control.setValue(null);
316 control.enable();
317 }
318 this.current.rules = rules;
319 }
320
321 private replicatedRuleChange() {
322 if (this.form.getValue('poolType') !== 'replicated') {
323 return;
324 }
325 const control = this.form.get('size');
326 let size = this.form.getValue('size') || 3;
327 const min = this.getMinSize();
328 const max = this.getMaxSize();
329 if (size < min) {
330 size = min;
331 } else if (size > max) {
332 size = max;
333 }
334 if (size !== control.value) {
335 this.form.silentSet('size', size);
336 }
337 }
338
339 getMinSize(): number {
340 if (!this.info || this.info.osd_count < 1) {
341 return;
342 }
343 const rule = this.form.getValue('crushRule');
344 if (rule) {
345 return rule.min_size;
346 }
347 return 1;
348 }
349
350 getMaxSize(): number {
351 if (!this.info || this.info.osd_count < 1) {
352 return;
353 }
354 const osds: number = this.info.osd_count;
355 if (this.form.getValue('crushRule')) {
356 const max: number = this.form.get('crushRule').value.max_size;
357 if (max < osds) {
358 return max;
359 }
360 }
361 return osds;
362 }
363
364 private pgCalc() {
365 const poolType = this.form.getValue('poolType');
366 if (!this.info || this.form.get('pgNum').dirty || !poolType) {
367 return;
368 }
369 const pgMax = this.info.osd_count * 100;
370 const pgs =
371 poolType === 'replicated' ? this.replicatedPgCalc(pgMax) : this.erasurePgCalc(pgMax);
372 if (!pgs) {
373 return;
374 }
375 const oldValue = this.data.pgs;
376 this.alignPgs(pgs);
377 const newValue = this.data.pgs;
378 if (!this.externalPgChange) {
379 this.externalPgChange = oldValue !== newValue;
380 }
381 }
382
383 private replicatedPgCalc(pgs): number {
384 const sizeControl = this.form.get('size');
385 const size = sizeControl.value;
386 if (sizeControl.valid && size > 0) {
387 return pgs / size;
388 }
389 }
390
391 private erasurePgCalc(pgs): number {
392 const ecpControl = this.form.get('erasureProfile');
393 const ecp = ecpControl.value;
394 if ((ecpControl.valid || ecpControl.disabled) && ecp) {
395 return pgs / (ecp.k + ecp.m);
396 }
397 }
398
399 private alignPgs(pgs = this.form.getValue('pgNum')) {
400 this.setPgs(Math.round(this.calculatePgPower(pgs < 1 ? 1 : pgs)));
401 }
402
403 private setComplexValidators() {
404 if (this.editing) {
405 this.form
406 .get('name')
407 .setValidators([
408 this.form.get('name').validator,
409 CdValidators.custom(
410 'uniqueName',
411 (name) =>
412 this.data.pool &&
413 this.info &&
414 this.info.pool_names.indexOf(name) !== -1 &&
415 this.info.pool_names.indexOf(name) !==
416 this.info.pool_names.indexOf(this.data.pool.pool_name)
417 )
418 ]);
419 } else {
420 CdValidators.validateIf(
421 this.form.get('size'),
422 () => this.form.get('poolType').value === 'replicated',
423 [
424 CdValidators.custom(
425 'min',
426 (value) => this.form.getValue('size') && value < this.getMinSize()
427 ),
428 CdValidators.custom(
429 'max',
430 (value) => this.form.getValue('size') && this.getMaxSize() < value
431 )
432 ]
433 );
434 this.form
435 .get('name')
436 .setValidators([
437 this.form.get('name').validator,
438 CdValidators.custom(
439 'uniqueName',
440 (name) => this.info && this.info.pool_names.indexOf(name) !== -1
441 )
442 ]);
443 }
444 this.setCompressionValidators();
445 }
446
447 private setCompressionValidators() {
448 CdValidators.validateIf(this.form.get('minBlobSize'), () => this.hasCompressionEnabled(), [
449 Validators.min(0),
450 CdValidators.custom('maximum', (size) =>
451 this.oddBlobSize(size, this.form.getValue('maxBlobSize'))
452 )
453 ]);
454 CdValidators.validateIf(this.form.get('maxBlobSize'), () => this.hasCompressionEnabled(), [
455 Validators.min(0),
456 CdValidators.custom('minimum', (size) =>
457 this.oddBlobSize(this.form.getValue('minBlobSize'), size)
458 )
459 ]);
460 CdValidators.validateIf(this.form.get('ratio'), () => this.hasCompressionEnabled(), [
461 Validators.min(0),
462 Validators.max(1)
463 ]);
464 }
465
466 private oddBlobSize(minimum, maximum) {
467 minimum = this.formatter.toBytes(minimum);
468 maximum = this.formatter.toBytes(maximum);
469 return Boolean(minimum && maximum && minimum >= maximum);
470 }
471
472 hasCompressionEnabled() {
473 return this.form.getValue('mode') && this.form.get('mode').value.toLowerCase() !== 'none';
474 }
475
476 describeCrushStep(step: CrushStep) {
477 return [
478 step.op.replace('_', ' '),
479 step.item_name || '',
480 step.type ? step.num + ' type ' + step.type : ''
481 ].join(' ');
482 }
483
484 addErasureCodeProfile() {
485 this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadECPs());
486 this.bsModalService.show(ErasureCodeProfileFormComponent);
487 }
488
489 private reloadECPs() {
490 this.ecpService.list().subscribe((profiles: ErasureCodeProfile[]) => this.initEcp(profiles));
491 this.modalSubscription.unsubscribe();
492 }
493
494 deleteErasureCodeProfile() {
495 const ecp = this.form.getValue('erasureProfile');
496 if (!ecp) {
497 return;
498 }
499 const name = ecp.name;
500 this.modalSubscription = this.modalService.onHide.subscribe(() => this.reloadECPs());
501 this.modalService.show(CriticalConfirmationModalComponent, {
502 initialState: {
503 itemDescription: this.i18n('erasure code profile'),
504 itemNames: [name],
505 submitActionObservable: () =>
506 this.taskWrapper.wrapTaskAroundCall({
507 task: new FinishedTask('ecp/delete', { name: name }),
508 call: this.ecpService.delete(name)
509 })
510 }
511 });
512 }
513
514 submit() {
515 if (this.form.invalid) {
516 this.form.setErrors({ cdSubmitButton: true });
517 return;
518 }
519
520 const pool = { pool: this.form.getValue('name') };
521
522 this.assignFormFields(pool, [
523 { externalFieldName: 'pool_type', formControlName: 'poolType' },
524 { externalFieldName: 'pg_num', formControlName: 'pgNum', editable: true },
525 this.form.getValue('poolType') === 'replicated'
526 ? { externalFieldName: 'size', formControlName: 'size' }
527 : {
528 externalFieldName: 'erasure_code_profile',
529 formControlName: 'erasureProfile',
530 attr: 'name'
531 },
532 { externalFieldName: 'rule_name', formControlName: 'crushRule', attr: 'rule_name' }
533 ]);
534
535 if (this.info.is_all_bluestore) {
536 this.assignFormField(pool, {
537 externalFieldName: 'flags',
538 formControlName: 'ecOverwrites',
539 replaceFn: () => ['ec_overwrites']
540 });
541
542 if (this.form.getValue('mode') !== 'none') {
543 this.assignFormFields(pool, [
544 {
545 externalFieldName: 'compression_mode',
546 formControlName: 'mode',
547 editable: true,
548 replaceFn: (value) => this.hasCompressionEnabled() && value
549 },
550 {
551 externalFieldName: 'compression_algorithm',
552 formControlName: 'algorithm',
553 editable: true
554 },
555 {
556 externalFieldName: 'compression_min_blob_size',
557 formControlName: 'minBlobSize',
558 replaceFn: this.formatter.toBytes,
559 editable: true,
560 resetValue: 0
561 },
562 {
563 externalFieldName: 'compression_max_blob_size',
564 formControlName: 'maxBlobSize',
565 replaceFn: this.formatter.toBytes,
566 editable: true,
567 resetValue: 0
568 },
569 {
570 externalFieldName: 'compression_required_ratio',
571 formControlName: 'ratio',
572 editable: true,
573 resetValue: 0
574 }
575 ]);
576 } else if (this.editing) {
577 this.assignFormFields(pool, [
578 {
579 externalFieldName: 'compression_mode',
580 formControlName: 'mode',
581 editable: true,
582 replaceFn: () => 'unset' // Is used if no compression is set
583 },
584 {
585 externalFieldName: 'srcpool',
586 formControlName: 'name',
587 editable: true,
588 replaceFn: () => this.data.pool.pool_name
589 }
590 ]);
591 }
592 }
593
594 const apps = this.data.applications.selected;
595 if (apps.length > 0 || this.editing) {
596 pool['application_metadata'] = apps;
597 }
598
599 // Only collect configuration data for replicated pools, as QoS cannot be configured on EC
600 // pools. EC data pools inherit their settings from the corresponding replicated metadata pool.
601 if (
602 this.form.get('poolType').value === 'replicated' &&
603 !_.isEmpty(this.currentConfigurationValues)
604 ) {
605 pool['configuration'] = this.currentConfigurationValues;
606 }
607
608 this.triggerApiTask(pool);
609 }
610
611 /**
612 * Retrieves the values for the given form field descriptions and assigns the values to the given
613 * object. This method differentiates between `add` and `edit` mode and acts differently on one or
614 * the other.
615 */
616 private assignFormFields(pool: object, formFieldDescription: FormFieldDescription[]): void {
617 formFieldDescription.forEach((item) => this.assignFormField(pool, item));
618 }
619
620 /**
621 * Retrieves the value for the given form field description and assigns the values to the given
622 * object. This method differentiates between `add` and `edit` mode and acts differently on one or
623 * the other.
624 */
625 private assignFormField(
626 pool: object,
627 {
628 externalFieldName,
629 formControlName,
630 attr,
631 replaceFn,
632 editable,
633 resetValue
634 }: FormFieldDescription
635 ): void {
636 if (this.editing && (!editable || this.form.get(formControlName).pristine)) {
637 return;
638 }
639 const value = this.form.getValue(formControlName);
640 let apiValue = replaceFn ? replaceFn(value) : attr ? _.get(value, attr) : value;
641 if (!value || !apiValue) {
642 if (editable && !_.isUndefined(resetValue)) {
643 apiValue = resetValue;
644 } else {
645 return;
646 }
647 }
648 pool[externalFieldName] = apiValue;
649 }
650
651 private triggerApiTask(pool) {
652 this.taskWrapper
653 .wrapTaskAroundCall({
654 task: new FinishedTask('pool/' + (this.editing ? URLVerbs.EDIT : URLVerbs.CREATE), {
655 pool_name: pool.hasOwnProperty('srcpool') ? pool.srcpool : pool.pool
656 }),
657 call: this.poolService[this.editing ? URLVerbs.UPDATE : URLVerbs.CREATE](pool)
658 })
659 .subscribe(
660 undefined,
661 (resp) => {
662 if (_.isObject(resp.error) && resp.error.code === '34') {
663 this.form.get('pgNum').setErrors({ '34': true });
664 }
665 this.form.setErrors({ cdSubmitButton: true });
666 },
667 () => this.router.navigate(['/pool'])
668 );
669 }
670
671 appSelection() {
672 this.form.updateValueAndValidity({ emitEvent: false, onlySelf: true });
673 }
674 }