]> git.apps.os.sepia.ceph.com Git - ceph.git/blob
812176717a1dd9a7f2efcda0657304bd05df0b84
[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     });
294   }
295
296   private setLoadingIndicator(path: string, loading: boolean) {
297     this.loading[path] = loading;
298     this.unsetLoadingIndicator();
299   }
300
301   private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
302     return tree.filter((d) => d.parent === path);
303   }
304
305   private getChildren(path: string): any[] {
306     const subTree = this.getSubTree(path);
307     return _.sortBy(this.getSubDirectories(path), 'path').map((dir) =>
308       this.createNode(dir, subTree)
309     );
310   }
311
312   private createNode(dir: CephfsDir, subTree?: CephfsDir[]): any {
313     this.nodeIds[dir.path] = dir;
314     if (!subTree) {
315       this.getSubTree(dir.parent);
316     }
317     return {
318       name: dir.name,
319       id: dir.path,
320       hasChildren: this.getSubDirectories(dir.path, subTree).length > 0
321     };
322   }
323
324   private getSubTree(path: string): CephfsDir[] {
325     return this.dirs.filter((d) => d.parent && d.parent.startsWith(path));
326   }
327
328   private setSettings(node: TreeNode) {
329     const readable = (value: number, fn?: (arg0: number) => number | string): number | string =>
330       value ? (fn ? fn(value) : value) : '';
331
332     this.settings = [
333       this.getQuota(node, 'max_files', readable),
334       this.getQuota(node, 'max_bytes', (value) =>
335         readable(value, (v) => this.dimlessBinaryPipe.transform(v))
336       )
337     ];
338   }
339
340   private getQuota(
341     tree: TreeNode,
342     quotaKey: string,
343     valueConvertFn: (number: number) => number | string
344   ): QuotaSetting {
345     // Get current maximum
346     const currentPath = tree.id;
347     tree = this.getOrigin(tree, quotaKey);
348     const dir = this.getDirectory(tree);
349     const value = dir.quotas[quotaKey];
350     // Get next tree maximum
351     // => The value that isn't changeable through a change of the current directories quota value
352     let nextMaxValue = value;
353     let nextMaxPath = dir.path;
354     if (tree.id === currentPath) {
355       if (tree.parent.id === '/') {
356         // The value will never inherit any other value, so it has no maximum.
357         nextMaxValue = 0;
358       } else {
359         const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey));
360         nextMaxValue = nextMaxDir.quotas[quotaKey];
361         nextMaxPath = nextMaxDir.path;
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: TreeNode, quotaSetting: string): TreeNode {
393     if (tree.parent && tree.parent.id !== '/') {
394       const current = this.getQuotaFromTree(tree, quotaSetting);
395
396       // Get the next used quota and node above the current one (until it hits the root directory)
397       const originTree = this.getOrigin(tree.parent, quotaSetting);
398       const inherited = this.getQuotaFromTree(originTree, quotaSetting);
399
400       // Select if the current quota is in use or the above
401       const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
402       return useOrigin ? originTree : tree;
403     }
404     return tree;
405   }
406
407   private getQuotaFromTree(tree: TreeNode, quotaSetting: string): number {
408     return this.getDirectory(tree).quotas[quotaSetting];
409   }
410
411   private getDirectory(node: TreeNode): CephfsDir {
412     const path = node.id as string;
413     return this.nodeIds[path];
414   }
415
416   selectOrigin(path: string) {
417     this.selectNode(this.getNode(path));
418   }
419
420   private getNode(path: string): TreeNode {
421     return this.treeComponent.treeModel.getNodeById(path);
422   }
423
424   updateQuotaModal() {
425     const path = this.selectedDir.path;
426     const selection: QuotaSetting = this.quota.selection.first();
427     const nextMax = selection.nextTreeMaximum;
428     const key = selection.quotaKey;
429     const value = selection.dirValue;
430     this.modalService.show(FormModalComponent, {
431       titleText: this.getModalQuotaTitle(
432         value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
433         path
434       ),
435       message: nextMax.value
436         ? $localize`The inherited ${this.getQuotaValueFromPathMsg(
437             nextMax.value,
438             nextMax.path
439           )} is the maximum value to be used.`
440         : undefined,
441       fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
442       submitButtonText: $localize`Save`,
443       onSubmit: (values: CephfsQuotas) => this.updateQuota(values)
444     });
445   }
446
447   private getModalQuotaTitle(action: string, path: string): string {
448     return $localize`${action} CephFS ${this.getQuotaName()} quota for '${path}'`;
449   }
450
451   private getQuotaName(): string {
452     return this.isBytesQuotaSelected() ? $localize`size` : $localize`files`;
453   }
454
455   private isBytesQuotaSelected(): boolean {
456     return this.quota.selection.first().quotaKey === 'max_bytes';
457   }
458
459   private getQuotaValueFromPathMsg(value: number, path: string): string {
460     value = this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value;
461
462     return $localize`${this.getQuotaName()} quota ${value} from '${path}'`;
463   }
464
465   private getQuotaFormField(
466     label: string,
467     name: string,
468     value: number,
469     maxValue: number
470   ): CdFormModalFieldConfig {
471     const isBinary = name === 'max_bytes';
472     const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
473     if (maxValue) {
474       formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
475     }
476     const field: CdFormModalFieldConfig = {
477       type: isBinary ? 'binary' : 'number',
478       label,
479       name,
480       value,
481       validators: formValidators,
482       required: true
483     };
484     if (!isBinary) {
485       field.errors = {
486         min: $localize`Value has to be at least 0 or more`,
487         max: $localize`Value has to be at most ${maxValue} or less`
488       };
489     }
490     return field;
491   }
492
493   private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
494     const path = this.selectedDir.path;
495     const key = this.quota.selection.first().quotaKey;
496     const action =
497       this.selectedDir.quotas[key] === 0
498         ? this.actionLabels.SET
499         : values[key] === 0
500         ? this.actionLabels.UNSET
501         : $localize`Updated`;
502     this.cephfsService.quota(this.id, path, values).subscribe(() => {
503       if (onSuccess) {
504         onSuccess();
505       }
506       this.notificationService.show(
507         NotificationType.success,
508         this.getModalQuotaTitle(action, path)
509       );
510       this.forceDirRefresh();
511     });
512   }
513
514   unsetQuotaModal() {
515     const path = this.selectedDir.path;
516     const selection: QuotaSetting = this.quota.selection.first();
517     const key = selection.quotaKey;
518     const nextMax = selection.nextTreeMaximum;
519     const dirValue = selection.dirValue;
520
521     const quotaValue = this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path);
522     const conclusion =
523       nextMax.value > 0
524         ? nextMax.value > dirValue
525           ? $localize`in order to inherit ${quotaValue}`
526           : $localize`which isn't used because of the inheritance of ${quotaValue}`
527         : $localize`in order to have no quota on the directory`;
528
529     this.modalRef = this.modalService.show(ConfirmationModalComponent, {
530       titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
531       buttonText: this.actionLabels.UNSET,
532       description: $localize`${this.actionLabels.UNSET} ${this.getQuotaValueFromPathMsg(
533         dirValue,
534         path
535       )} ${conclusion}.`,
536       onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalRef.close())
537     });
538   }
539
540   createSnapshot() {
541     // Create a snapshot. Auto-generate a snapshot name by default.
542     const path = this.selectedDir.path;
543     this.modalService.show(FormModalComponent, {
544       titleText: $localize`Create Snapshot`,
545       message: $localize`Please enter the name of the snapshot.`,
546       fields: [
547         {
548           type: 'text',
549           name: 'name',
550           value: `${moment().toISOString(true)}`,
551           required: true,
552           validators: [this.validateValue.bind(this)]
553         }
554       ],
555       submitButtonText: $localize`Create Snapshot`,
556       onSubmit: (values: CephfsSnapshot) => {
557         if (!this.alreadyExists) {
558           this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
559             this.notificationService.show(
560               NotificationType.success,
561               $localize`Created snapshot '${name}' for '${path}'`
562             );
563             this.forceDirRefresh();
564           });
565         } else {
566           this.notificationService.show(
567             NotificationType.error,
568             $localize`Snapshot name '${values.name}' is already in use. Please use another name.`
569           );
570         }
571       }
572     });
573   }
574
575   validateValue(control: AbstractControl) {
576     this.alreadyExists = this.selectedDir.snapshots.some((s) => s.name === control.value);
577   }
578
579   /**
580    * Forces an update of the current selected directory
581    *
582    * As all nodes point by their path on an directory object, the easiest way is to update
583    * the objects by merge with their latest change.
584    */
585   private forceDirRefresh(path?: string) {
586     if (!path) {
587       const dir = this.selectedDir;
588       if (!dir) {
589         throw new Error('This function can only be called without path if an selection was made');
590       }
591       // Parent has to be called in order to update the object referring
592       // to the current selected directory
593       path = dir.parent ? dir.parent : dir.path;
594     }
595     const node = this.getNode(path);
596     node.loadNodeChildren();
597   }
598
599   private updateTreeStructure(dirs: CephfsDir[]) {
600     const getChildrenAndPaths = (
601       directories: CephfsDir[],
602       parent: string
603     ): { children: CephfsDir[]; paths: string[] } => {
604       const children = directories.filter((d) => d.parent === parent);
605       const paths = children.map((d) => d.path);
606       return { children, paths };
607     };
608
609     const parents = _.uniq(dirs.map((d) => d.parent).sort());
610     parents.forEach((p) => {
611       const received = getChildrenAndPaths(dirs, p);
612       const cached = getChildrenAndPaths(this.dirs, p);
613
614       cached.children.forEach((d) => {
615         if (!received.paths.includes(d.path)) {
616           this.removeOldDirectory(d);
617         }
618       });
619       received.children.forEach((d) => {
620         if (cached.paths.includes(d.path)) {
621           this.updateExistingDirectory(cached.children, d);
622         } else {
623           this.addNewDirectory(d);
624         }
625       });
626     });
627   }
628
629   private removeOldDirectory(rmDir: CephfsDir) {
630     const path = rmDir.path;
631     // Remove directory from local variables
632     _.remove(this.dirs, (d) => d.path === path);
633     delete this.nodeIds[path];
634     this.updateDirectoriesParentNode(rmDir);
635   }
636
637   private updateDirectoriesParentNode(dir: CephfsDir) {
638     const parent = dir.parent;
639     if (!parent) {
640       return;
641     }
642     const node = this.getNode(parent);
643     if (!node) {
644       // Node will not be found for new sub directories - this is the intended behaviour
645       return;
646     }
647     const children = this.getChildren(parent);
648     node.data.children = children;
649     node.data.hasChildren = children.length > 0;
650     this.treeComponent.treeModel.update();
651   }
652
653   private addNewDirectory(newDir: CephfsDir) {
654     this.dirs.push(newDir);
655     this.nodeIds[newDir.path] = newDir;
656     this.updateDirectoriesParentNode(newDir);
657   }
658
659   private updateExistingDirectory(source: CephfsDir[], updatedDir: CephfsDir) {
660     const currentDirObject = source.find((sub) => sub.path === updatedDir.path);
661     Object.assign(currentDirObject, updatedDir);
662   }
663
664   private updateQuotaTable() {
665     const node = this.selectedDir ? this.getNode(this.selectedDir.path) : undefined;
666     if (node && node.id !== '/') {
667       this.setSettings(node);
668     }
669   }
670
671   private updateTree(force: boolean = false) {
672     if (this.loadingIndicator && !force) {
673       // In order to make the page scrollable during load, the render cycle for each node
674       // is omitted and only be called if all updates were loaded.
675       return;
676     }
677     this.treeComponent.treeModel.update();
678     this.nodes = [...this.nodes];
679     this.treeComponent.sizeChanged();
680   }
681
682   deleteSnapshotModal() {
683     this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
684       itemDescription: $localize`CephFs Snapshot`,
685       itemNames: this.snapshot.selection.selected.map((snapshot: CephfsSnapshot) => snapshot.name),
686       submitAction: () => this.deleteSnapshot()
687     });
688   }
689
690   deleteSnapshot() {
691     const path = this.selectedDir.path;
692     this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => {
693       const name = snapshot.name;
694       this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => {
695         this.notificationService.show(
696           NotificationType.success,
697           $localize`Deleted snapshot '${name}' for '${path}'`
698         );
699       });
700     });
701     this.modalRef.close();
702     this.forceDirRefresh();
703   }
704
705   refreshAllDirectories() {
706     // In order to make the page scrollable during load, the render cycle for each node
707     // is omitted and only be called if all updates were loaded.
708     this.loadingIndicator = true;
709     this.requestedPaths.map((path) => this.forceDirRefresh(path));
710     const interval = setInterval(() => {
711       this.updateTree(true);
712       if (!this.loadingIndicator) {
713         clearInterval(interval);
714       }
715     }, 3000);
716   }
717
718   unsetLoadingIndicator() {
719     if (!this.loadingIndicator) {
720       return;
721     }
722     clearTimeout(this.loadingTimeout);
723     this.loadingTimeout = setTimeout(() => {
724       const loading = Object.values(this.loading).some((l) => l);
725       if (loading) {
726         return this.unsetLoadingIndicator();
727       }
728       this.loadingIndicator = false;
729       this.updateTree();
730       // The problem is that we can't subscribe to an useful updated tree event and the time
731       // between fetching all calls and rebuilding the tree can take some time
732     }, 3000);
733   }
734 }