1 import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
2 import { Validators } from '@angular/forms';
4 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
11 } from 'angular-tree-component';
12 import _ from 'lodash';
13 import moment from 'moment';
15 import { CephfsService } from '../../../shared/api/cephfs.service';
16 import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component';
17 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
18 import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component';
19 import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
20 import { Icons } from '../../../shared/enum/icons.enum';
21 import { NotificationType } from '../../../shared/enum/notification-type.enum';
22 import { CdValidators } from '../../../shared/forms/cd-validators';
23 import { CdFormModalFieldConfig } from '../../../shared/models/cd-form-modal-field-config';
24 import { CdTableAction } from '../../../shared/models/cd-table-action';
25 import { CdTableColumn } from '../../../shared/models/cd-table-column';
26 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
31 } from '../../../shared/models/cephfs-directory-models';
32 import { Permission } from '../../../shared/models/permissions';
33 import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
34 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
35 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
36 import { ModalService } from '../../../shared/services/modal.service';
37 import { NotificationService } from '../../../shared/services/notification.service';
41 // Used in quota table
43 value: number | string;
55 selector: 'cd-cephfs-directories',
56 templateUrl: './cephfs-directories.component.html',
57 styleUrls: ['./cephfs-directories.component.scss']
59 export class CephfsDirectoriesComponent implements OnInit, OnChanges {
60 @ViewChild(TreeComponent)
61 treeComponent: TreeComponent;
62 @ViewChild('origin', { static: true })
63 originTmpl: TemplateRef<any>;
68 private modalRef: NgbModalRef;
69 private dirs: CephfsDir[];
70 private nodeIds: { [path: string]: CephfsDir };
71 private requestedPaths: string[];
72 private loadingTimeout: any;
75 loadingIndicator = false;
77 treeOptions: ITreeOptions = {
78 useVirtualScroll: true,
79 getChildren: (node: TreeNode): Promise<any[]> => {
80 return this.updateDirectory(node.id);
84 click: this.selectAndShowNode.bind(this),
85 expanderClick: this.selectAndShowNode.bind(this)
90 permission: Permission;
91 selectedDir: CephfsDir;
92 settings: QuotaSetting[];
94 columns: CdTableColumn[];
95 selection: CdTableSelection;
96 tableActions: CdTableAction[];
97 updateSelection: Function;
100 columns: CdTableColumn[];
101 selection: CdTableSelection;
102 tableActions: CdTableAction[];
103 updateSelection: Function;
108 private authStorageService: AuthStorageService,
109 private modalService: ModalService,
110 private cephfsService: CephfsService,
111 private cdDatePipe: CdDatePipe,
112 private actionLabels: ActionLabelsI18n,
113 private notificationService: NotificationService,
114 private dimlessBinaryPipe: DimlessBinaryPipe
117 private selectAndShowNode(tree: TreeModel, node: TreeNode, $event: any) {
118 TREE_ACTIONS.TOGGLE_EXPANDED(tree, node, $event);
119 this.selectNode(node);
122 private selectNode(node: TreeNode) {
123 TREE_ACTIONS.TOGGLE_ACTIVE(undefined, node, undefined);
124 this.selectedDir = this.getDirectory(node);
125 if (node.id === '/') {
128 this.setSettings(node);
132 this.permission = this.authStorageService.getPermissions().cephfs;
133 this.setUpQuotaTable();
134 this.setUpSnapshotTable();
137 private setUpQuotaTable() {
142 name: $localize`Name`,
147 name: $localize`Value`,
152 prop: 'row.originPath',
153 name: $localize`Origin`,
155 cellTemplate: this.originTmpl,
159 selection: new CdTableSelection(),
160 updateSelection: (selection: CdTableSelection) => {
161 this.quota.selection = selection;
165 name: this.actionLabels.SET,
167 permission: 'update',
168 visible: (selection) =>
169 !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
170 click: () => this.updateQuotaModal()
173 name: this.actionLabels.UPDATE,
175 permission: 'update',
176 visible: (selection) => selection.first() && selection.first().dirValue > 0,
177 click: () => this.updateQuotaModal()
180 name: this.actionLabels.UNSET,
182 permission: 'update',
183 disable: (selection) =>
184 !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
185 click: () => this.unsetQuotaModal()
191 private setUpSnapshotTable() {
196 name: $localize`Name`,
201 name: $localize`Path`,
207 name: $localize`Created`,
209 pipe: this.cdDatePipe
212 selection: new CdTableSelection(),
213 updateSelection: (selection: CdTableSelection) => {
214 this.snapshot.selection = selection;
218 name: this.actionLabels.CREATE,
220 permission: 'create',
221 canBePrimary: (selection) => !selection.hasSelection,
222 click: () => this.createSnapshot()
225 name: this.actionLabels.DELETE,
227 permission: 'delete',
228 click: () => this.deleteSnapshotModal(),
229 canBePrimary: (selection) => selection.hasSelection,
230 disable: (selection) => !selection.hasSelection
237 this.selectedDir = undefined;
239 this.requestedPaths = [];
247 private setRootNode() {
257 private firstCall() {
260 this.getNode(path).loadNodeChildren();
264 updateDirectory(path: string): Promise<any[]> {
265 this.unsetLoadingIndicator();
266 if (!this.requestedPaths.includes(path)) {
267 this.requestedPaths.push(path);
268 } else if (this.loading[path] === true) {
269 return undefined; // Path is currently fetched.
271 return new Promise((resolve) => {
272 this.setLoadingIndicator(path, true);
273 this.cephfsService.lsDir(this.id, path).subscribe((dirs) => {
274 this.updateTreeStructure(dirs);
275 this.updateQuotaTable();
277 resolve(this.getChildren(path));
278 this.setLoadingIndicator(path, false);
283 private setLoadingIndicator(path: string, loading: boolean) {
284 this.loading[path] = loading;
285 this.unsetLoadingIndicator();
288 private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
289 return tree.filter((d) => d.parent === path);
292 private getChildren(path: string): any[] {
293 const subTree = this.getSubTree(path);
294 return _.sortBy(this.getSubDirectories(path), 'path').map((dir) =>
295 this.createNode(dir, subTree)
299 private createNode(dir: CephfsDir, subTree?: CephfsDir[]): any {
300 this.nodeIds[dir.path] = dir;
302 this.getSubTree(dir.parent);
307 hasChildren: this.getSubDirectories(dir.path, subTree).length > 0
311 private getSubTree(path: string): CephfsDir[] {
312 return this.dirs.filter((d) => d.parent && d.parent.startsWith(path));
315 private setSettings(node: TreeNode) {
316 const readable = (value: number, fn?: (arg0: number) => number | string): number | string =>
317 value ? (fn ? fn(value) : value) : '';
320 this.getQuota(node, 'max_files', readable),
321 this.getQuota(node, 'max_bytes', (value) =>
322 readable(value, (v) => this.dimlessBinaryPipe.transform(v))
330 valueConvertFn: (number: number) => number | string
332 // Get current maximum
333 const currentPath = tree.id;
334 tree = this.getOrigin(tree, quotaKey);
335 const dir = this.getDirectory(tree);
336 const value = dir.quotas[quotaKey];
337 // Get next tree maximum
338 // => The value that isn't changeable through a change of the current directories quota value
339 let nextMaxValue = value;
340 let nextMaxPath = dir.path;
341 if (tree.id === currentPath) {
342 if (tree.parent.id === '/') {
343 // The value will never inherit any other value, so it has no maximum.
346 const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey));
347 nextMaxValue = nextMaxDir.quotas[quotaKey];
348 nextMaxPath = nextMaxDir.path;
353 name: quotaKey === 'max_bytes' ? $localize`Max size` : $localize`Max files`,
354 value: valueConvertFn(value),
355 originPath: value ? dir.path : ''
358 dirValue: this.nodeIds[currentPath].quotas[quotaKey],
361 path: nextMaxValue ? nextMaxPath : ''
367 * Get the node where the quota limit originates from in the current node
369 * Example as it's a recursive method:
371 * | Path + Value | Call depth | useOrigin? | Output |
372 * |:-------------:|:----------:|:---------------------:|:------:|
373 * | /a/b/c/d (15) | 1st | 2nd (5) < 15 => false | /a/b |
374 * | /a/b/c (20) | 2nd | 3rd (5) < 20 => false | /a/b |
375 * | /a/b (5) | 3rd | 4th (10) < 5 => true | /a/b |
376 * | /a (10) | 4th | 10 => true | /a |
379 private getOrigin(tree: TreeNode, quotaSetting: string): TreeNode {
380 if (tree.parent && tree.parent.id !== '/') {
381 const current = this.getQuotaFromTree(tree, quotaSetting);
383 // Get the next used quota and node above the current one (until it hits the root directory)
384 const originTree = this.getOrigin(tree.parent, quotaSetting);
385 const inherited = this.getQuotaFromTree(originTree, quotaSetting);
387 // Select if the current quota is in use or the above
388 const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
389 return useOrigin ? originTree : tree;
394 private getQuotaFromTree(tree: TreeNode, quotaSetting: string): number {
395 return this.getDirectory(tree).quotas[quotaSetting];
398 private getDirectory(node: TreeNode): CephfsDir {
399 const path = node.id as string;
400 return this.nodeIds[path];
403 selectOrigin(path: string) {
404 this.selectNode(this.getNode(path));
407 private getNode(path: string): TreeNode {
408 return this.treeComponent.treeModel.getNodeById(path);
412 const path = this.selectedDir.path;
413 const selection: QuotaSetting = this.quota.selection.first();
414 const nextMax = selection.nextTreeMaximum;
415 const key = selection.quotaKey;
416 const value = selection.dirValue;
417 this.modalService.show(FormModalComponent, {
418 titleText: this.getModalQuotaTitle(
419 value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
422 message: nextMax.value
423 ? $localize`The inherited ${this.getQuotaValueFromPathMsg(
426 )} is the maximum value to be used.`
428 fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
429 submitButtonText: $localize`Save`,
430 onSubmit: (values: CephfsQuotas) => this.updateQuota(values)
434 private getModalQuotaTitle(action: string, path: string): string {
435 return $localize`${action} CephFS ${this.getQuotaName()} quota for '${path}'`;
438 private getQuotaName(): string {
439 return this.isBytesQuotaSelected() ? $localize`size` : $localize`files`;
442 private isBytesQuotaSelected(): boolean {
443 return this.quota.selection.first().quotaKey === 'max_bytes';
446 private getQuotaValueFromPathMsg(value: number, path: string): string {
447 value = this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value;
449 return $localize`${this.getQuotaName()} quota ${value} from '${path}'`;
452 private getQuotaFormField(
457 ): CdFormModalFieldConfig {
458 const isBinary = name === 'max_bytes';
459 const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
461 formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
463 const field: CdFormModalFieldConfig = {
464 type: isBinary ? 'binary' : 'number',
468 validators: formValidators,
473 min: $localize`Value has to be at least 0 or more`,
474 max: $localize`Value has to be at most ${maxValue} or less`
480 private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
481 const path = this.selectedDir.path;
482 const key = this.quota.selection.first().quotaKey;
484 this.selectedDir.quotas[key] === 0
485 ? this.actionLabels.SET
487 ? this.actionLabels.UNSET
488 : $localize`Updated`;
489 this.cephfsService.quota(this.id, path, values).subscribe(() => {
493 this.notificationService.show(
494 NotificationType.success,
495 this.getModalQuotaTitle(action, path)
497 this.forceDirRefresh();
502 const path = this.selectedDir.path;
503 const selection: QuotaSetting = this.quota.selection.first();
504 const key = selection.quotaKey;
505 const nextMax = selection.nextTreeMaximum;
506 const dirValue = selection.dirValue;
508 const quotaValue = this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path);
511 ? nextMax.value > dirValue
512 ? $localize`in order to inherit ${quotaValue}`
513 : $localize`which isn't used because of the inheritance of ${quotaValue}`
514 : $localize`in order to have no quota on the directory`;
516 this.modalRef = this.modalService.show(ConfirmationModalComponent, {
517 titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
518 buttonText: this.actionLabels.UNSET,
519 description: $localize`${this.actionLabels.UNSET} ${this.getQuotaValueFromPathMsg(
523 onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalRef.close())
528 // Create a snapshot. Auto-generate a snapshot name by default.
529 const path = this.selectedDir.path;
530 this.modalService.show(FormModalComponent, {
531 titleText: $localize`Create Snapshot`,
532 message: $localize`Please enter the name of the snapshot.`,
537 value: `${moment().toISOString(true)}`,
541 submitButtonText: $localize`Create Snapshot`,
542 onSubmit: (values: CephfsSnapshot) => {
543 this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
544 this.notificationService.show(
545 NotificationType.success,
546 $localize`Created snapshot '${name}' for '${path}'`
548 this.forceDirRefresh();
555 * Forces an update of the current selected directory
557 * As all nodes point by their path on an directory object, the easiest way is to update
558 * the objects by merge with their latest change.
560 private forceDirRefresh(path?: string) {
562 const dir = this.selectedDir;
564 throw new Error('This function can only be called without path if an selection was made');
566 // Parent has to be called in order to update the object referring
567 // to the current selected directory
568 path = dir.parent ? dir.parent : dir.path;
570 const node = this.getNode(path);
571 node.loadNodeChildren();
574 private updateTreeStructure(dirs: CephfsDir[]) {
575 const getChildrenAndPaths = (
576 directories: CephfsDir[],
578 ): { children: CephfsDir[]; paths: string[] } => {
579 const children = directories.filter((d) => d.parent === parent);
580 const paths = children.map((d) => d.path);
581 return { children, paths };
584 const parents = _.uniq(dirs.map((d) => d.parent).sort());
585 parents.forEach((p) => {
586 const received = getChildrenAndPaths(dirs, p);
587 const cached = getChildrenAndPaths(this.dirs, p);
589 cached.children.forEach((d) => {
590 if (!received.paths.includes(d.path)) {
591 this.removeOldDirectory(d);
594 received.children.forEach((d) => {
595 if (cached.paths.includes(d.path)) {
596 this.updateExistingDirectory(cached.children, d);
598 this.addNewDirectory(d);
604 private removeOldDirectory(rmDir: CephfsDir) {
605 const path = rmDir.path;
606 // Remove directory from local variables
607 _.remove(this.dirs, (d) => d.path === path);
608 delete this.nodeIds[path];
609 this.updateDirectoriesParentNode(rmDir);
612 private updateDirectoriesParentNode(dir: CephfsDir) {
613 const parent = dir.parent;
617 const node = this.getNode(parent);
619 // Node will not be found for new sub sub directories - this is the intended behaviour
622 const children = this.getChildren(parent);
623 node.data.children = children;
624 node.data.hasChildren = children.length > 0;
625 this.treeComponent.treeModel.update();
628 private addNewDirectory(newDir: CephfsDir) {
629 this.dirs.push(newDir);
630 this.nodeIds[newDir.path] = newDir;
631 this.updateDirectoriesParentNode(newDir);
634 private updateExistingDirectory(source: CephfsDir[], updatedDir: CephfsDir) {
635 const currentDirObject = source.find((sub) => sub.path === updatedDir.path);
636 Object.assign(currentDirObject, updatedDir);
639 private updateQuotaTable() {
640 const node = this.selectedDir ? this.getNode(this.selectedDir.path) : undefined;
641 if (node && node.id !== '/') {
642 this.setSettings(node);
646 private updateTree(force: boolean = false) {
647 if (this.loadingIndicator && !force) {
648 // In order to make the page scrollable during load, the render cycle for each node
649 // is omitted and only be called if all updates were loaded.
652 this.treeComponent.treeModel.update();
653 this.nodes = [...this.nodes];
654 this.treeComponent.sizeChanged();
657 deleteSnapshotModal() {
658 this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
659 itemDescription: $localize`CephFs Snapshot`,
660 itemNames: this.snapshot.selection.selected.map((snapshot: CephfsSnapshot) => snapshot.name),
661 submitAction: () => this.deleteSnapshot()
666 const path = this.selectedDir.path;
667 this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => {
668 const name = snapshot.name;
669 this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => {
670 this.notificationService.show(
671 NotificationType.success,
672 $localize`Deleted snapshot '${name}' for '${path}'`
676 this.modalRef.close();
677 this.forceDirRefresh();
680 refreshAllDirectories() {
681 // In order to make the page scrollable during load, the render cycle for each node
682 // is omitted and only be called if all updates were loaded.
683 this.loadingIndicator = true;
684 this.requestedPaths.map((path) => this.forceDirRefresh(path));
685 const interval = setInterval(() => {
686 this.updateTree(true);
687 if (!this.loadingIndicator) {
688 clearInterval(interval);
693 unsetLoadingIndicator() {
694 if (!this.loadingIndicator) {
697 clearTimeout(this.loadingTimeout);
698 this.loadingTimeout = setTimeout(() => {
699 const loading = Object.values(this.loading).some((l) => l);
701 return this.unsetLoadingIndicator();
703 this.loadingIndicator = false;
705 // The problem is that we can't subscribe to an useful updated tree event and the time
706 // between fetching all calls and rebuilding the tree can take some time