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