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