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