]> git.apps.os.sepia.ceph.com Git - ceph.git/blob
ca93b8e67f3f31c9434113ccc28b8449f1d06b6a
[ceph.git] /
1 import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
2 import { Validators } from '@angular/forms';
3
4 import { I18n } from '@ngx-translate/i18n-polyfill';
5 import * as _ from 'lodash';
6 import * as moment from 'moment';
7 import { NodeEvent, Tree, TreeComponent, TreeModel } from 'ng2-tree';
8 import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
9
10 import { CephfsService } from '../../../shared/api/cephfs.service';
11 import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component';
12 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
13 import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component';
14 import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
15 import { Icons } from '../../../shared/enum/icons.enum';
16 import { NotificationType } from '../../../shared/enum/notification-type.enum';
17 import { CdValidators } from '../../../shared/forms/cd-validators';
18 import { CdFormModalFieldConfig } from '../../../shared/models/cd-form-modal-field-config';
19 import { CdTableAction } from '../../../shared/models/cd-table-action';
20 import { CdTableColumn } from '../../../shared/models/cd-table-column';
21 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
22 import {
23   CephfsDir,
24   CephfsQuotas,
25   CephfsSnapshot
26 } from '../../../shared/models/cephfs-directory-models';
27 import { Permission } from '../../../shared/models/permissions';
28 import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
29 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
30 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
31 import { NotificationService } from '../../../shared/services/notification.service';
32
33 class QuotaSetting {
34   row: {
35     // Shows quota that is used for current directory
36     name: string;
37     value: number | string;
38     originPath: string;
39   };
40   quotaKey: string;
41   dirValue: number;
42   nextTreeMaximum: {
43     value: number;
44     path: string;
45   };
46 }
47
48 @Component({
49   selector: 'cd-cephfs-directories',
50   templateUrl: './cephfs-directories.component.html',
51   styleUrls: ['./cephfs-directories.component.scss']
52 })
53 export class CephfsDirectoriesComponent implements OnInit, OnChanges {
54   @ViewChild(TreeComponent, { static: true })
55   treeComponent: TreeComponent;
56   @ViewChild('origin', { static: true })
57   originTmpl: TemplateRef<any>;
58
59   @Input()
60   id: number;
61
62   private modalRef: BsModalRef;
63   private dirs: CephfsDir[];
64   private nodeIds: { [path: string]: CephfsDir };
65   private requestedPaths: string[];
66   private selectedNode: Tree;
67
68   permission: Permission;
69   selectedDir: CephfsDir;
70   settings: QuotaSetting[];
71   quota: {
72     columns: CdTableColumn[];
73     selection: CdTableSelection;
74     tableActions: CdTableAction[];
75     updateSelection: Function;
76   };
77   snapshot: {
78     columns: CdTableColumn[];
79     selection: CdTableSelection;
80     tableActions: CdTableAction[];
81     updateSelection: Function;
82   };
83   tree: TreeModel;
84
85   constructor(
86     private authStorageService: AuthStorageService,
87     private modalService: BsModalService,
88     private cephfsService: CephfsService,
89     private cdDatePipe: CdDatePipe,
90     private i18n: I18n,
91     private actionLabels: ActionLabelsI18n,
92     private notificationService: NotificationService,
93     private dimlessBinaryPipe: DimlessBinaryPipe
94   ) {}
95
96   ngOnInit() {
97     this.permission = this.authStorageService.getPermissions().cephfs;
98     this.quota = {
99       columns: [
100         {
101           prop: 'row.name',
102           name: this.i18n('Name'),
103           flexGrow: 1
104         },
105         {
106           prop: 'row.value',
107           name: this.i18n('Value'),
108           sortable: false,
109           flexGrow: 1
110         },
111         {
112           prop: 'row.originPath',
113           name: this.i18n('Origin'),
114           sortable: false,
115           cellTemplate: this.originTmpl,
116           flexGrow: 1
117         }
118       ],
119       selection: new CdTableSelection(),
120       updateSelection: (selection: CdTableSelection) => {
121         this.quota.selection = selection;
122       },
123       tableActions: [
124         {
125           name: this.actionLabels.SET,
126           icon: Icons.edit,
127           permission: 'update',
128           visible: (selection) =>
129             !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
130           click: () => this.updateQuotaModal()
131         },
132         {
133           name: this.actionLabels.UPDATE,
134           icon: Icons.edit,
135           permission: 'update',
136           visible: (selection) => selection.first() && selection.first().dirValue > 0,
137           click: () => this.updateQuotaModal()
138         },
139         {
140           name: this.actionLabels.UNSET,
141           icon: Icons.destroy,
142           permission: 'update',
143           disable: (selection) =>
144             !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
145           click: () => this.unsetQuotaModal()
146         }
147       ]
148     };
149     this.snapshot = {
150       columns: [
151         {
152           prop: 'name',
153           name: this.i18n('Name'),
154           flexGrow: 1
155         },
156         {
157           prop: 'path',
158           name: this.i18n('Path'),
159           isHidden: true,
160           flexGrow: 2
161         },
162         {
163           prop: 'created',
164           name: this.i18n('Created'),
165           flexGrow: 1,
166           pipe: this.cdDatePipe
167         }
168       ],
169       selection: new CdTableSelection(),
170       updateSelection: (selection: CdTableSelection) => {
171         this.snapshot.selection = selection;
172       },
173       tableActions: [
174         {
175           name: this.actionLabels.CREATE,
176           icon: Icons.add,
177           permission: 'create',
178           canBePrimary: (selection) => !selection.hasSelection,
179           click: () => this.createSnapshot()
180         },
181         {
182           name: this.actionLabels.DELETE,
183           icon: Icons.destroy,
184           permission: 'delete',
185           click: () => this.deleteSnapshotModal(),
186           canBePrimary: (selection) => selection.hasSelection,
187           disable: (selection) => !selection.hasSelection
188         }
189       ]
190     };
191   }
192
193   ngOnChanges() {
194     this.selectedDir = undefined;
195     this.dirs = [];
196     this.requestedPaths = [];
197     this.nodeIds = {};
198     if (_.isUndefined(this.id)) {
199       this.setRootNode([]);
200     } else {
201       this.firstCall();
202     }
203   }
204
205   private setRootNode(nodes: TreeModel[]) {
206     const tree: TreeModel = {
207       value: '/',
208       id: '/',
209       settings: {
210         selectionAllowed: false,
211         static: true
212       }
213     };
214     if (nodes.length > 0) {
215       tree.children = nodes;
216     }
217     this.tree = tree;
218   }
219
220   private firstCall() {
221     this.updateDirectory('/', (nodes) => this.setRootNode(nodes));
222   }
223
224   updateDirectory(path: string, callback: (x: any[]) => void) {
225     if (
226       !this.requestedPaths.includes(path) &&
227       (path === '/' || this.getSubDirectories(path).length > 0)
228     ) {
229       this.requestedPaths.push(path);
230       this.cephfsService
231         .lsDir(this.id, path)
232         .subscribe((data) => this.loadDirectory(data, path, callback));
233     } else {
234       this.getChildren(path, callback);
235     }
236   }
237
238   private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
239     return tree.filter((d) => d.parent === path);
240   }
241
242   private loadDirectory(data: CephfsDir[], path: string, callback: (x: any[]) => void) {
243     if (path !== '/') {
244       // As always to levels are loaded all sub-directories of the current called path are
245       // already loaded, that's why they are filtered out.
246       data = data.filter((dir) => dir.parent !== path);
247     }
248     this.dirs = this.dirs.concat(data);
249     this.getChildren(path, callback);
250   }
251
252   private getChildren(path: string, callback: (x: any[]) => void) {
253     const subTree = this.getSubTree(path);
254     const nodes = _.sortBy(this.getSubDirectories(path), 'path').map((d) => {
255       this.nodeIds[d.path] = d;
256       const newNode: TreeModel = {
257         value: d.name,
258         id: d.path,
259         settings: { static: true }
260       };
261       if (this.getSubDirectories(d.path, subTree).length > 0) {
262         // LoadChildren will be triggered if a node is expanded
263         newNode.loadChildren = (treeCallback) => this.updateDirectory(d.path, treeCallback);
264       }
265       return newNode;
266     });
267     callback(nodes);
268   }
269
270   private getSubTree(path: string): CephfsDir[] {
271     return this.dirs.filter((d) => d.parent.startsWith(path));
272   }
273
274   selectOrigin(path) {
275     this.treeComponent.getControllerByNodeId(path).select();
276   }
277
278   onNodeSelected(e: NodeEvent) {
279     const node = e.node;
280     this.treeComponent.getControllerByNodeId(node.id).expand();
281     this.setSettings(node);
282     this.selectedDir = this.getDirectory(node);
283     this.selectedNode = node;
284   }
285
286   private setSettings(node: Tree) {
287     const readable = (value: number, fn?: (number) => number | string): number | string =>
288       value ? (fn ? fn(value) : value) : '';
289
290     this.settings = [
291       this.getQuota(node, 'max_files', readable),
292       this.getQuota(node, 'max_bytes', (value) =>
293         readable(value, (v) => this.dimlessBinaryPipe.transform(v))
294       )
295     ];
296   }
297
298   private getQuota(
299     tree: Tree,
300     quotaKey: string,
301     valueConvertFn: (number) => number | string
302   ): QuotaSetting {
303     // Get current maximum
304     const currentPath = tree.id;
305     tree = this.getOrigin(tree, quotaKey);
306     const dir = this.getDirectory(tree);
307     const value = dir.quotas[quotaKey];
308     // Get next tree maximum
309     // => The value that isn't changeable through a change of the current directories quota value
310     let nextMaxValue = value;
311     let nextMaxPath = dir.path;
312     if (tree.id === currentPath) {
313       if (tree.parent.value === '/') {
314         // The value will never inherit any other value, so it has no maximum.
315         nextMaxValue = 0;
316       } else {
317         const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey));
318         nextMaxValue = nextMaxDir.quotas[quotaKey];
319         nextMaxPath = nextMaxDir.path;
320       }
321     }
322     return {
323       row: {
324         name: quotaKey === 'max_bytes' ? this.i18n('Max size') : this.i18n('Max files'),
325         value: valueConvertFn(value),
326         originPath: value ? dir.path : ''
327       },
328       quotaKey,
329       dirValue: this.nodeIds[currentPath].quotas[quotaKey],
330       nextTreeMaximum: {
331         value: nextMaxValue,
332         path: nextMaxValue ? nextMaxPath : ''
333       }
334     };
335   }
336
337   private getOrigin(tree: Tree, quotaSetting: string): Tree {
338     if (tree.parent.value !== '/') {
339       const current = this.getQuotaFromTree(tree, quotaSetting);
340       const originTree = this.getOrigin(tree.parent, quotaSetting);
341       const inherited = this.getQuotaFromTree(originTree, quotaSetting);
342
343       const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
344       return useOrigin ? originTree : tree;
345     }
346     return tree;
347   }
348
349   private getQuotaFromTree(tree: Tree, quotaSetting: string): number {
350     return this.getDirectory(tree).quotas[quotaSetting];
351   }
352
353   private getDirectory(node: Tree): CephfsDir {
354     const path = node.id as string;
355     return this.nodeIds[path];
356   }
357
358   updateQuotaModal() {
359     const path = this.selectedDir.path;
360     const selection: QuotaSetting = this.quota.selection.first();
361     const nextMax = selection.nextTreeMaximum;
362     const key = selection.quotaKey;
363     const value = selection.dirValue;
364     this.modalService.show(FormModalComponent, {
365       initialState: {
366         titleText: this.getModalQuotaTitle(
367           value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
368           path
369         ),
370         message: nextMax.value
371           ? this.i18n('The inherited {{quotaValue}} is the maximum value to be used.', {
372               quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
373             })
374           : undefined,
375         fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
376         submitButtonText: 'Save',
377         onSubmit: (values) => this.updateQuota(values)
378       }
379     });
380   }
381
382   private getModalQuotaTitle(action: string, path: string): string {
383     return this.i18n("{{action}} CephFS {{quotaName}} quota for '{{path}}'", {
384       action,
385       quotaName: this.getQuotaName(),
386       path
387     });
388   }
389
390   private getQuotaName(): string {
391     return this.isBytesQuotaSelected() ? this.i18n('size') : this.i18n('files');
392   }
393
394   private isBytesQuotaSelected(): boolean {
395     return this.quota.selection.first().quotaKey === 'max_bytes';
396   }
397
398   private getQuotaValueFromPathMsg(value: number, path: string): string {
399     return this.i18n("{{quotaName}} quota {{value}} from '{{path}}'", {
400       value: this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value,
401       quotaName: this.getQuotaName(),
402       path
403     });
404   }
405
406   private getQuotaFormField(
407     label: string,
408     name: string,
409     value: number,
410     maxValue: number
411   ): CdFormModalFieldConfig {
412     const isBinary = name === 'max_bytes';
413     const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
414     if (maxValue) {
415       formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
416     }
417     const field: CdFormModalFieldConfig = {
418       type: isBinary ? 'binary' : 'number',
419       label,
420       name,
421       value,
422       validators: formValidators,
423       required: true
424     };
425     if (!isBinary) {
426       field.errors = {
427         min: this.i18n(`Value has to be at least {{value}} or more`, { value: 0 }),
428         max: this.i18n(`Value has to be at most {{value}} or less`, { value: maxValue })
429       };
430     }
431     return field;
432   }
433
434   private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
435     const path = this.selectedDir.path;
436     const key = this.quota.selection.first().quotaKey;
437     const action =
438       this.selectedDir.quotas[key] === 0
439         ? this.actionLabels.SET
440         : values[key] === 0
441         ? this.actionLabels.UNSET
442         : this.i18n('Updated');
443     this.cephfsService.updateQuota(this.id, path, values).subscribe(() => {
444       if (onSuccess) {
445         onSuccess();
446       }
447       this.notificationService.show(
448         NotificationType.success,
449         this.getModalQuotaTitle(action, path)
450       );
451       this.forceDirRefresh();
452     });
453   }
454
455   unsetQuotaModal() {
456     const path = this.selectedDir.path;
457     const selection: QuotaSetting = this.quota.selection.first();
458     const key = selection.quotaKey;
459     const nextMax = selection.nextTreeMaximum;
460     const dirValue = selection.dirValue;
461
462     this.modalRef = this.modalService.show(ConfirmationModalComponent, {
463       initialState: {
464         titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
465         buttonText: this.actionLabels.UNSET,
466         description: this.i18n(`{{action}} {{quotaValue}} {{conclusion}}.`, {
467           action: this.actionLabels.UNSET,
468           quotaValue: this.getQuotaValueFromPathMsg(dirValue, path),
469           conclusion:
470             nextMax.value > 0
471               ? nextMax.value > dirValue
472                 ? this.i18n('in order to inherit {{quotaValue}}', {
473                     quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
474                   })
475                 : this.i18n("which isn't used because of the inheritance of {{quotaValue}}", {
476                     quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
477                   })
478               : this.i18n('in order to have no quota on the directory')
479         }),
480         onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalRef.hide())
481       }
482     });
483   }
484
485   createSnapshot() {
486     // Create a snapshot. Auto-generate a snapshot name by default.
487     const path = this.selectedDir.path;
488     this.modalService.show(FormModalComponent, {
489       initialState: {
490         titleText: this.i18n('Create Snapshot'),
491         message: this.i18n('Please enter the name of the snapshot.'),
492         fields: [
493           {
494             type: 'text',
495             name: 'name',
496             value: `${moment().toISOString(true)}`,
497             required: true
498           }
499         ],
500         submitButtonText: this.i18n('Create Snapshot'),
501         onSubmit: (values) => {
502           this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
503             this.notificationService.show(
504               NotificationType.success,
505               this.i18n('Created snapshot "{{name}}" for "{{path}}"', {
506                 name: name,
507                 path: path
508               })
509             );
510             this.forceDirRefresh();
511           });
512         }
513       }
514     });
515   }
516
517   /**
518    * Forces an update of the current selected directory
519    *
520    * As all nodes point by their path on an directory object, the easiest way is to update
521    * the objects by merge with their latest change.
522    */
523   private forceDirRefresh() {
524     const path = this.selectedNode.parent.id as string;
525     this.cephfsService.lsDir(this.id, path).subscribe((data) => {
526       data.forEach((d) => {
527         Object.assign(this.dirs.find((sub) => sub.path === d.path), d);
528       });
529       // Now update quotas
530       this.setSettings(this.selectedNode);
531     });
532   }
533
534   deleteSnapshotModal() {
535     this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
536       initialState: {
537         itemDescription: this.i18n('CephFs Snapshot'),
538         itemNames: this.snapshot.selection.selected.map(
539           (snapshot: CephfsSnapshot) => snapshot.name
540         ),
541         submitAction: () => this.deleteSnapshot()
542       }
543     });
544   }
545
546   deleteSnapshot() {
547     const path = this.selectedDir.path;
548     this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => {
549       const name = snapshot.name;
550       this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => {
551         this.notificationService.show(
552           NotificationType.success,
553           this.i18n('Deleted snapshot "{{name}}" for "{{path}}"', {
554             name: name,
555             path: path
556           })
557         );
558       });
559     });
560     this.modalRef.hide();
561     this.forceDirRefresh();
562   }
563 }