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