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