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