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