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