]> git.apps.os.sepia.ceph.com Git - ceph.git/blob
3480223dd954620bbf38788c8095f511cee57b57
[ceph.git] /
1 import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
2 import { Validators } from '@angular/forms';
3
4 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
5 import {
6   ITreeOptions,
7   TreeComponent,
8   TreeModel,
9   TreeNode,
10   TREE_ACTIONS
11 } from 'angular-tree-component';
12 import _ from 'lodash';
13 import moment from 'moment';
14
15 import { CephfsService } from '../../../shared/api/cephfs.service';
16 import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component';
17 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
18 import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component';
19 import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
20 import { Icons } from '../../../shared/enum/icons.enum';
21 import { NotificationType } from '../../../shared/enum/notification-type.enum';
22 import { CdValidators } from '../../../shared/forms/cd-validators';
23 import { CdFormModalFieldConfig } from '../../../shared/models/cd-form-modal-field-config';
24 import { CdTableAction } from '../../../shared/models/cd-table-action';
25 import { CdTableColumn } from '../../../shared/models/cd-table-column';
26 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
27 import {
28   CephfsDir,
29   CephfsQuotas,
30   CephfsSnapshot
31 } from '../../../shared/models/cephfs-directory-models';
32 import { Permission } from '../../../shared/models/permissions';
33 import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
34 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
35 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
36 import { ModalService } from '../../../shared/services/modal.service';
37 import { NotificationService } from '../../../shared/services/notification.service';
38
39 class QuotaSetting {
40   row: {
41     // Used in quota table
42     name: string;
43     value: number | string;
44     originPath: string;
45   };
46   quotaKey: string;
47   dirValue: number;
48   nextTreeMaximum: {
49     value: number;
50     path: string;
51   };
52 }
53
54 @Component({
55   selector: 'cd-cephfs-directories',
56   templateUrl: './cephfs-directories.component.html',
57   styleUrls: ['./cephfs-directories.component.scss']
58 })
59 export class CephfsDirectoriesComponent implements OnInit, OnChanges {
60   @ViewChild(TreeComponent)
61   treeComponent: TreeComponent;
62   @ViewChild('origin', { static: true })
63   originTmpl: TemplateRef<any>;
64
65   @Input()
66   id: number;
67
68   private modalRef: NgbModalRef;
69   private dirs: CephfsDir[];
70   private nodeIds: { [path: string]: CephfsDir };
71   private requestedPaths: string[];
72   private loadingTimeout: any;
73
74   icons = Icons;
75   loadingIndicator = false;
76   loading = {};
77   treeOptions: ITreeOptions = {
78     useVirtualScroll: true,
79     getChildren: (node: TreeNode): Promise<any[]> => {
80       return this.updateDirectory(node.id);
81     },
82     actionMapping: {
83       mouse: {
84         click: this.selectAndShowNode.bind(this),
85         expanderClick: this.selectAndShowNode.bind(this)
86       }
87     }
88   };
89
90   permission: Permission;
91   selectedDir: CephfsDir;
92   settings: QuotaSetting[];
93   quota: {
94     columns: CdTableColumn[];
95     selection: CdTableSelection;
96     tableActions: CdTableAction[];
97     updateSelection: Function;
98   };
99   snapshot: {
100     columns: CdTableColumn[];
101     selection: CdTableSelection;
102     tableActions: CdTableAction[];
103     updateSelection: Function;
104   };
105   nodes: any[];
106
107   constructor(
108     private authStorageService: AuthStorageService,
109     private modalService: ModalService,
110     private cephfsService: CephfsService,
111     private cdDatePipe: CdDatePipe,
112     private actionLabels: ActionLabelsI18n,
113     private notificationService: NotificationService,
114     private dimlessBinaryPipe: DimlessBinaryPipe
115   ) {}
116
117   private selectAndShowNode(tree: TreeModel, node: TreeNode, $event: any) {
118     TREE_ACTIONS.TOGGLE_EXPANDED(tree, node, $event);
119     this.selectNode(node);
120   }
121
122   private selectNode(node: TreeNode) {
123     TREE_ACTIONS.TOGGLE_ACTIVE(undefined, node, undefined);
124     this.selectedDir = this.getDirectory(node);
125     if (node.id === '/') {
126       return;
127     }
128     this.setSettings(node);
129   }
130
131   ngOnInit() {
132     this.permission = this.authStorageService.getPermissions().cephfs;
133     this.setUpQuotaTable();
134     this.setUpSnapshotTable();
135   }
136
137   private setUpQuotaTable() {
138     this.quota = {
139       columns: [
140         {
141           prop: 'row.name',
142           name: $localize`Name`,
143           flexGrow: 1
144         },
145         {
146           prop: 'row.value',
147           name: $localize`Value`,
148           sortable: false,
149           flexGrow: 1
150         },
151         {
152           prop: 'row.originPath',
153           name: $localize`Origin`,
154           sortable: false,
155           cellTemplate: this.originTmpl,
156           flexGrow: 1
157         }
158       ],
159       selection: new CdTableSelection(),
160       updateSelection: (selection: CdTableSelection) => {
161         this.quota.selection = selection;
162       },
163       tableActions: [
164         {
165           name: this.actionLabels.SET,
166           icon: Icons.edit,
167           permission: 'update',
168           visible: (selection) =>
169             !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
170           click: () => this.updateQuotaModal()
171         },
172         {
173           name: this.actionLabels.UPDATE,
174           icon: Icons.edit,
175           permission: 'update',
176           visible: (selection) => selection.first() && selection.first().dirValue > 0,
177           click: () => this.updateQuotaModal()
178         },
179         {
180           name: this.actionLabels.UNSET,
181           icon: Icons.destroy,
182           permission: 'update',
183           disable: (selection) =>
184             !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
185           click: () => this.unsetQuotaModal()
186         }
187       ]
188     };
189   }
190
191   private setUpSnapshotTable() {
192     this.snapshot = {
193       columns: [
194         {
195           prop: 'name',
196           name: $localize`Name`,
197           flexGrow: 1
198         },
199         {
200           prop: 'path',
201           name: $localize`Path`,
202           isHidden: true,
203           flexGrow: 2
204         },
205         {
206           prop: 'created',
207           name: $localize`Created`,
208           flexGrow: 1,
209           pipe: this.cdDatePipe
210         }
211       ],
212       selection: new CdTableSelection(),
213       updateSelection: (selection: CdTableSelection) => {
214         this.snapshot.selection = selection;
215       },
216       tableActions: [
217         {
218           name: this.actionLabels.CREATE,
219           icon: Icons.add,
220           permission: 'create',
221           canBePrimary: (selection) => !selection.hasSelection,
222           click: () => this.createSnapshot()
223         },
224         {
225           name: this.actionLabels.DELETE,
226           icon: Icons.destroy,
227           permission: 'delete',
228           click: () => this.deleteSnapshotModal(),
229           canBePrimary: (selection) => selection.hasSelection,
230           disable: (selection) => !selection.hasSelection
231         }
232       ]
233     };
234   }
235
236   ngOnChanges() {
237     this.selectedDir = undefined;
238     this.dirs = [];
239     this.requestedPaths = [];
240     this.nodeIds = {};
241     if (this.id) {
242       this.setRootNode();
243       this.firstCall();
244     }
245   }
246
247   private setRootNode() {
248     this.nodes = [
249       {
250         name: '/',
251         id: '/',
252         isExpanded: true
253       }
254     ];
255   }
256
257   private firstCall() {
258     const path = '/';
259     setTimeout(() => {
260       this.getNode(path).loadNodeChildren();
261     }, 10);
262   }
263
264   updateDirectory(path: string): Promise<any[]> {
265     this.unsetLoadingIndicator();
266     if (!this.requestedPaths.includes(path)) {
267       this.requestedPaths.push(path);
268     } else if (this.loading[path] === true) {
269       return undefined; // Path is currently fetched.
270     }
271     return new Promise((resolve) => {
272       this.setLoadingIndicator(path, true);
273       this.cephfsService.lsDir(this.id, path).subscribe((dirs) => {
274         this.updateTreeStructure(dirs);
275         this.updateQuotaTable();
276         this.updateTree();
277         resolve(this.getChildren(path));
278         this.setLoadingIndicator(path, false);
279       });
280     });
281   }
282
283   private setLoadingIndicator(path: string, loading: boolean) {
284     this.loading[path] = loading;
285     this.unsetLoadingIndicator();
286   }
287
288   private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
289     return tree.filter((d) => d.parent === path);
290   }
291
292   private getChildren(path: string): any[] {
293     const subTree = this.getSubTree(path);
294     return _.sortBy(this.getSubDirectories(path), 'path').map((dir) =>
295       this.createNode(dir, subTree)
296     );
297   }
298
299   private createNode(dir: CephfsDir, subTree?: CephfsDir[]): any {
300     this.nodeIds[dir.path] = dir;
301     if (!subTree) {
302       this.getSubTree(dir.parent);
303     }
304     return {
305       name: dir.name,
306       id: dir.path,
307       hasChildren: this.getSubDirectories(dir.path, subTree).length > 0
308     };
309   }
310
311   private getSubTree(path: string): CephfsDir[] {
312     return this.dirs.filter((d) => d.parent && d.parent.startsWith(path));
313   }
314
315   private setSettings(node: TreeNode) {
316     const readable = (value: number, fn?: (arg0: number) => number | string): number | string =>
317       value ? (fn ? fn(value) : value) : '';
318
319     this.settings = [
320       this.getQuota(node, 'max_files', readable),
321       this.getQuota(node, 'max_bytes', (value) =>
322         readable(value, (v) => this.dimlessBinaryPipe.transform(v))
323       )
324     ];
325   }
326
327   private getQuota(
328     tree: TreeNode,
329     quotaKey: string,
330     valueConvertFn: (number: number) => number | string
331   ): QuotaSetting {
332     // Get current maximum
333     const currentPath = tree.id;
334     tree = this.getOrigin(tree, quotaKey);
335     const dir = this.getDirectory(tree);
336     const value = dir.quotas[quotaKey];
337     // Get next tree maximum
338     // => The value that isn't changeable through a change of the current directories quota value
339     let nextMaxValue = value;
340     let nextMaxPath = dir.path;
341     if (tree.id === currentPath) {
342       if (tree.parent.id === '/') {
343         // The value will never inherit any other value, so it has no maximum.
344         nextMaxValue = 0;
345       } else {
346         const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey));
347         nextMaxValue = nextMaxDir.quotas[quotaKey];
348         nextMaxPath = nextMaxDir.path;
349       }
350     }
351     return {
352       row: {
353         name: quotaKey === 'max_bytes' ? $localize`Max size` : $localize`Max files`,
354         value: valueConvertFn(value),
355         originPath: value ? dir.path : ''
356       },
357       quotaKey,
358       dirValue: this.nodeIds[currentPath].quotas[quotaKey],
359       nextTreeMaximum: {
360         value: nextMaxValue,
361         path: nextMaxValue ? nextMaxPath : ''
362       }
363     };
364   }
365
366   /**
367    * Get the node where the quota limit originates from in the current node
368    *
369    * Example as it's a recursive method:
370    *
371    * |  Path + Value | Call depth |       useOrigin?      | Output |
372    * |:-------------:|:----------:|:---------------------:|:------:|
373    * | /a/b/c/d (15) |     1st    | 2nd (5) < 15 => false |  /a/b  |
374    * | /a/b/c (20)   |     2nd    | 3rd (5) < 20 => false |  /a/b  |
375    * | /a/b (5)      |     3rd    |  4th (10) < 5 => true |  /a/b  |
376    * | /a (10)       |     4th    |       10 => true      |   /a   |
377    *
378    */
379   private getOrigin(tree: TreeNode, quotaSetting: string): TreeNode {
380     if (tree.parent && tree.parent.id !== '/') {
381       const current = this.getQuotaFromTree(tree, quotaSetting);
382
383       // Get the next used quota and node above the current one (until it hits the root directory)
384       const originTree = this.getOrigin(tree.parent, quotaSetting);
385       const inherited = this.getQuotaFromTree(originTree, quotaSetting);
386
387       // Select if the current quota is in use or the above
388       const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
389       return useOrigin ? originTree : tree;
390     }
391     return tree;
392   }
393
394   private getQuotaFromTree(tree: TreeNode, quotaSetting: string): number {
395     return this.getDirectory(tree).quotas[quotaSetting];
396   }
397
398   private getDirectory(node: TreeNode): CephfsDir {
399     const path = node.id as string;
400     return this.nodeIds[path];
401   }
402
403   selectOrigin(path: string) {
404     this.selectNode(this.getNode(path));
405   }
406
407   private getNode(path: string): TreeNode {
408     return this.treeComponent.treeModel.getNodeById(path);
409   }
410
411   updateQuotaModal() {
412     const path = this.selectedDir.path;
413     const selection: QuotaSetting = this.quota.selection.first();
414     const nextMax = selection.nextTreeMaximum;
415     const key = selection.quotaKey;
416     const value = selection.dirValue;
417     this.modalService.show(FormModalComponent, {
418       titleText: this.getModalQuotaTitle(
419         value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
420         path
421       ),
422       message: nextMax.value
423         ? $localize`The inherited ${this.getQuotaValueFromPathMsg(
424             nextMax.value,
425             nextMax.path
426           )} is the maximum value to be used.`
427         : undefined,
428       fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
429       submitButtonText: $localize`Save`,
430       onSubmit: (values: CephfsQuotas) => this.updateQuota(values)
431     });
432   }
433
434   private getModalQuotaTitle(action: string, path: string): string {
435     return $localize`${action} CephFS ${this.getQuotaName()} quota for '${path}'`;
436   }
437
438   private getQuotaName(): string {
439     return this.isBytesQuotaSelected() ? $localize`size` : $localize`files`;
440   }
441
442   private isBytesQuotaSelected(): boolean {
443     return this.quota.selection.first().quotaKey === 'max_bytes';
444   }
445
446   private getQuotaValueFromPathMsg(value: number, path: string): string {
447     value = this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value;
448
449     return $localize`${this.getQuotaName()} quota ${value} from '${path}'`;
450   }
451
452   private getQuotaFormField(
453     label: string,
454     name: string,
455     value: number,
456     maxValue: number
457   ): CdFormModalFieldConfig {
458     const isBinary = name === 'max_bytes';
459     const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
460     if (maxValue) {
461       formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
462     }
463     const field: CdFormModalFieldConfig = {
464       type: isBinary ? 'binary' : 'number',
465       label,
466       name,
467       value,
468       validators: formValidators,
469       required: true
470     };
471     if (!isBinary) {
472       field.errors = {
473         min: $localize`Value has to be at least 0 or more`,
474         max: $localize`Value has to be at most ${maxValue} or less`
475       };
476     }
477     return field;
478   }
479
480   private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
481     const path = this.selectedDir.path;
482     const key = this.quota.selection.first().quotaKey;
483     const action =
484       this.selectedDir.quotas[key] === 0
485         ? this.actionLabels.SET
486         : values[key] === 0
487         ? this.actionLabels.UNSET
488         : $localize`Updated`;
489     this.cephfsService.quota(this.id, path, values).subscribe(() => {
490       if (onSuccess) {
491         onSuccess();
492       }
493       this.notificationService.show(
494         NotificationType.success,
495         this.getModalQuotaTitle(action, path)
496       );
497       this.forceDirRefresh();
498     });
499   }
500
501   unsetQuotaModal() {
502     const path = this.selectedDir.path;
503     const selection: QuotaSetting = this.quota.selection.first();
504     const key = selection.quotaKey;
505     const nextMax = selection.nextTreeMaximum;
506     const dirValue = selection.dirValue;
507
508     const quotaValue = this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path);
509     const conclusion =
510       nextMax.value > 0
511         ? nextMax.value > dirValue
512           ? $localize`in order to inherit ${quotaValue}`
513           : $localize`which isn't used because of the inheritance of ${quotaValue}`
514         : $localize`in order to have no quota on the directory`;
515
516     this.modalRef = this.modalService.show(ConfirmationModalComponent, {
517       titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
518       buttonText: this.actionLabels.UNSET,
519       description: $localize`${this.actionLabels.UNSET} ${this.getQuotaValueFromPathMsg(
520         dirValue,
521         path
522       )} ${conclusion}.`,
523       onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalRef.close())
524     });
525   }
526
527   createSnapshot() {
528     // Create a snapshot. Auto-generate a snapshot name by default.
529     const path = this.selectedDir.path;
530     this.modalService.show(FormModalComponent, {
531       titleText: $localize`Create Snapshot`,
532       message: $localize`Please enter the name of the snapshot.`,
533       fields: [
534         {
535           type: 'text',
536           name: 'name',
537           value: `${moment().toISOString(true)}`,
538           required: true
539         }
540       ],
541       submitButtonText: $localize`Create Snapshot`,
542       onSubmit: (values: CephfsSnapshot) => {
543         this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
544           this.notificationService.show(
545             NotificationType.success,
546             $localize`Created snapshot '${name}' for '${path}'`
547           );
548           this.forceDirRefresh();
549         });
550       }
551     });
552   }
553
554   /**
555    * Forces an update of the current selected directory
556    *
557    * As all nodes point by their path on an directory object, the easiest way is to update
558    * the objects by merge with their latest change.
559    */
560   private forceDirRefresh(path?: string) {
561     if (!path) {
562       const dir = this.selectedDir;
563       if (!dir) {
564         throw new Error('This function can only be called without path if an selection was made');
565       }
566       // Parent has to be called in order to update the object referring
567       // to the current selected directory
568       path = dir.parent ? dir.parent : dir.path;
569     }
570     const node = this.getNode(path);
571     node.loadNodeChildren();
572   }
573
574   private updateTreeStructure(dirs: CephfsDir[]) {
575     const getChildrenAndPaths = (
576       directories: CephfsDir[],
577       parent: string
578     ): { children: CephfsDir[]; paths: string[] } => {
579       const children = directories.filter((d) => d.parent === parent);
580       const paths = children.map((d) => d.path);
581       return { children, paths };
582     };
583
584     const parents = _.uniq(dirs.map((d) => d.parent).sort());
585     parents.forEach((p) => {
586       const received = getChildrenAndPaths(dirs, p);
587       const cached = getChildrenAndPaths(this.dirs, p);
588
589       cached.children.forEach((d) => {
590         if (!received.paths.includes(d.path)) {
591           this.removeOldDirectory(d);
592         }
593       });
594       received.children.forEach((d) => {
595         if (cached.paths.includes(d.path)) {
596           this.updateExistingDirectory(cached.children, d);
597         } else {
598           this.addNewDirectory(d);
599         }
600       });
601     });
602   }
603
604   private removeOldDirectory(rmDir: CephfsDir) {
605     const path = rmDir.path;
606     // Remove directory from local variables
607     _.remove(this.dirs, (d) => d.path === path);
608     delete this.nodeIds[path];
609     this.updateDirectoriesParentNode(rmDir);
610   }
611
612   private updateDirectoriesParentNode(dir: CephfsDir) {
613     const parent = dir.parent;
614     if (!parent) {
615       return;
616     }
617     const node = this.getNode(parent);
618     if (!node) {
619       // Node will not be found for new sub sub directories - this is the intended behaviour
620       return;
621     }
622     const children = this.getChildren(parent);
623     node.data.children = children;
624     node.data.hasChildren = children.length > 0;
625     this.treeComponent.treeModel.update();
626   }
627
628   private addNewDirectory(newDir: CephfsDir) {
629     this.dirs.push(newDir);
630     this.nodeIds[newDir.path] = newDir;
631     this.updateDirectoriesParentNode(newDir);
632   }
633
634   private updateExistingDirectory(source: CephfsDir[], updatedDir: CephfsDir) {
635     const currentDirObject = source.find((sub) => sub.path === updatedDir.path);
636     Object.assign(currentDirObject, updatedDir);
637   }
638
639   private updateQuotaTable() {
640     const node = this.selectedDir ? this.getNode(this.selectedDir.path) : undefined;
641     if (node && node.id !== '/') {
642       this.setSettings(node);
643     }
644   }
645
646   private updateTree(force: boolean = false) {
647     if (this.loadingIndicator && !force) {
648       // In order to make the page scrollable during load, the render cycle for each node
649       // is omitted and only be called if all updates were loaded.
650       return;
651     }
652     this.treeComponent.treeModel.update();
653     this.nodes = [...this.nodes];
654     this.treeComponent.sizeChanged();
655   }
656
657   deleteSnapshotModal() {
658     this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
659       itemDescription: $localize`CephFs Snapshot`,
660       itemNames: this.snapshot.selection.selected.map((snapshot: CephfsSnapshot) => snapshot.name),
661       submitAction: () => this.deleteSnapshot()
662     });
663   }
664
665   deleteSnapshot() {
666     const path = this.selectedDir.path;
667     this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => {
668       const name = snapshot.name;
669       this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => {
670         this.notificationService.show(
671           NotificationType.success,
672           $localize`Deleted snapshot '${name}' for '${path}'`
673         );
674       });
675     });
676     this.modalRef.close();
677     this.forceDirRefresh();
678   }
679
680   refreshAllDirectories() {
681     // In order to make the page scrollable during load, the render cycle for each node
682     // is omitted and only be called if all updates were loaded.
683     this.loadingIndicator = true;
684     this.requestedPaths.map((path) => this.forceDirRefresh(path));
685     const interval = setInterval(() => {
686       this.updateTree(true);
687       if (!this.loadingIndicator) {
688         clearInterval(interval);
689       }
690     }, 3000);
691   }
692
693   unsetLoadingIndicator() {
694     if (!this.loadingIndicator) {
695       return;
696     }
697     clearTimeout(this.loadingTimeout);
698     this.loadingTimeout = setTimeout(() => {
699       const loading = Object.values(this.loading).some((l) => l);
700       if (loading) {
701         return this.unsetLoadingIndicator();
702       }
703       this.loadingIndicator = false;
704       this.updateTree();
705       // The problem is that we can't subscribe to an useful updated tree event and the time
706       // between fetching all calls and rebuilding the tree can take some time
707     }, 3000);
708   }
709 }