1 import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
2 import { AbstractControl, Validators } from '@angular/forms';
10 } from '@circlon/angular-tree-component';
11 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
12 import _ from 'lodash';
13 import moment from 'moment';
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';
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';
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;
106 alreadyExists: boolean;
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
118 private selectAndShowNode(tree: TreeModel, node: TreeNode, $event: any) {
119 TREE_ACTIONS.TOGGLE_EXPANDED(tree, node, $event);
120 this.selectNode(node);
123 private selectNode(node: TreeNode) {
124 TREE_ACTIONS.TOGGLE_ACTIVE(undefined, node, undefined);
125 this.selectedDir = this.getDirectory(node);
126 if (node.id === '/') {
129 this.setSettings(node);
133 this.permission = this.authStorageService.getPermissions().cephfs;
134 this.setUpQuotaTable();
135 this.setUpSnapshotTable();
138 private setUpQuotaTable() {
143 name: $localize`Name`,
148 name: $localize`Value`,
153 prop: 'row.originPath',
154 name: $localize`Origin`,
156 cellTemplate: this.originTmpl,
160 selection: new CdTableSelection(),
161 updateSelection: (selection: CdTableSelection) => {
162 this.quota.selection = selection;
166 name: this.actionLabels.SET,
168 permission: 'update',
169 visible: (selection) =>
170 !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
171 click: () => this.updateQuotaModal()
174 name: this.actionLabels.UPDATE,
176 permission: 'update',
177 visible: (selection) => selection.first() && selection.first().dirValue > 0,
178 click: () => this.updateQuotaModal()
181 name: this.actionLabels.UNSET,
183 permission: 'update',
184 disable: (selection) =>
185 !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
186 click: () => this.unsetQuotaModal()
192 private setUpSnapshotTable() {
197 name: $localize`Name`,
202 name: $localize`Path`,
208 name: $localize`Created`,
210 pipe: this.cdDatePipe
214 name: $localize`Capacity`,
218 selection: new CdTableSelection(),
219 updateSelection: (selection: CdTableSelection) => {
220 this.snapshot.selection = selection;
224 name: this.actionLabels.CREATE,
226 permission: 'create',
227 canBePrimary: (selection) => !selection.hasSelection,
228 click: () => this.createSnapshot(),
229 disable: () => this.disableCreateSnapshot()
232 name: this.actionLabels.DELETE,
234 permission: 'delete',
235 click: () => this.deleteSnapshotModal(),
236 canBePrimary: (selection) => selection.hasSelection,
237 disable: (selection) => !selection.hasSelection
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]}`;
254 this.selectedDir = undefined;
256 this.requestedPaths = [];
264 private setRootNode() {
274 private firstCall() {
277 this.getNode(path).loadNodeChildren();
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.
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();
294 resolve(this.getChildren(path));
295 this.setLoadingIndicator(path, false);
297 if (path === '/' && this.treeComponent.treeModel.activeNodes?.length === 0) {
298 this.selectNode(this.getNode('/'));
304 private setLoadingIndicator(path: string, loading: boolean) {
305 this.loading[path] = loading;
306 this.unsetLoadingIndicator();
309 private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
310 return tree.filter((d) => d.parent === path);
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)
320 private createNode(dir: CephfsDir, subTree?: CephfsDir[]): any {
321 this.nodeIds[dir.path] = dir;
323 this.getSubTree(dir.parent);
326 if (dir.path === '/volumes') {
327 const innerNode = this.treeComponent.treeModel.getNodeById('/volumes');
335 hasChildren: this.getSubDirectories(dir.path, subTree).length > 0
339 private getSubTree(path: string): CephfsDir[] {
340 return this.dirs.filter((d) => d.parent && d.parent.startsWith(path));
343 private setSettings(node: TreeNode) {
344 const readable = (value: number, fn?: (arg0: number) => number | string): number | string =>
345 value ? (fn ? fn(value) : value) : '';
348 this.getQuota(node, 'max_files', readable),
349 this.getQuota(node, 'max_bytes', (value) =>
350 readable(value, (v) => this.dimlessBinaryPipe.transform(v))
358 valueConvertFn: (number: number) => number | string
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.
374 const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey));
375 nextMaxValue = nextMaxDir.quotas[quotaKey];
376 nextMaxPath = nextMaxDir.path;
381 name: quotaKey === 'max_bytes' ? $localize`Max size` : $localize`Max files`,
382 value: valueConvertFn(value),
383 originPath: value ? dir.path : ''
386 dirValue: this.nodeIds[currentPath].quotas[quotaKey],
389 path: nextMaxValue ? nextMaxPath : ''
395 * Get the node where the quota limit originates from in the current node
397 * Example as it's a recursive method:
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 |
407 private getOrigin(tree: TreeNode, quotaSetting: string): TreeNode {
408 if (tree.parent && tree.parent.id !== '/') {
409 const current = this.getQuotaFromTree(tree, quotaSetting);
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);
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;
422 private getQuotaFromTree(tree: TreeNode, quotaSetting: string): number {
423 return this.getDirectory(tree).quotas[quotaSetting];
426 private getDirectory(node: TreeNode): CephfsDir {
427 const path = node.id as string;
428 return this.nodeIds[path];
431 selectOrigin(path: string) {
432 this.selectNode(this.getNode(path));
435 private getNode(path: string): TreeNode {
436 return this.treeComponent.treeModel.getNodeById(path);
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,
450 message: nextMax.value
451 ? $localize`The inherited ${this.getQuotaValueFromPathMsg(
454 )} is the maximum value to be used.`
456 fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
457 submitButtonText: $localize`Save`,
458 onSubmit: (values: CephfsQuotas) => this.updateQuota(values)
462 private getModalQuotaTitle(action: string, path: string): string {
463 return $localize`${action} CephFS ${this.getQuotaName()} quota for '${path}'`;
466 private getQuotaName(): string {
467 return this.isBytesQuotaSelected() ? $localize`size` : $localize`files`;
470 private isBytesQuotaSelected(): boolean {
471 return this.quota.selection.first().quotaKey === 'max_bytes';
474 private getQuotaValueFromPathMsg(value: number, path: string): string {
475 value = this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value;
477 return $localize`${this.getQuotaName()} quota ${value} from '${path}'`;
480 private getQuotaFormField(
485 ): CdFormModalFieldConfig {
486 const isBinary = name === 'max_bytes';
487 const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
489 formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
491 const field: CdFormModalFieldConfig = {
492 type: isBinary ? 'binary' : 'number',
496 validators: formValidators,
501 min: $localize`Value has to be at least 0 or more`,
502 max: $localize`Value has to be at most ${maxValue} or less`
508 private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
509 const path = this.selectedDir.path;
510 const key = this.quota.selection.first().quotaKey;
512 this.selectedDir.quotas[key] === 0
513 ? this.actionLabels.SET
515 ? this.actionLabels.UNSET
516 : $localize`Updated`;
517 this.cephfsService.quota(this.id, path, values).subscribe(() => {
521 this.notificationService.show(
522 NotificationType.success,
523 this.getModalQuotaTitle(action, path)
525 this.forceDirRefresh();
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;
536 const quotaValue = this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path);
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`;
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(
551 onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalRef.close())
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.`,
565 value: `${moment().toISOString(true)}`,
567 validators: [this.validateValue.bind(this)]
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}'`
578 this.forceDirRefresh();
581 this.notificationService.show(
582 NotificationType.error,
583 $localize`Snapshot name '${values.name}' is already in use. Please use another name.`
590 validateValue(control: AbstractControl) {
591 this.alreadyExists = this.selectedDir.snapshots.some((s) => s.name === control.value);
595 * Forces an update of the current selected directory
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.
600 private forceDirRefresh(path?: string) {
602 const dir = this.selectedDir;
604 throw new Error('This function can only be called without path if an selection was made');
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;
610 const node = this.getNode(path);
611 node.loadNodeChildren();
614 private updateTreeStructure(dirs: CephfsDir[]) {
615 const getChildrenAndPaths = (
616 directories: CephfsDir[],
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 };
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);
629 cached.children.forEach((d) => {
630 if (!received.paths.includes(d.path)) {
631 this.removeOldDirectory(d);
634 received.children.forEach((d) => {
635 if (cached.paths.includes(d.path)) {
636 this.updateExistingDirectory(cached.children, d);
638 this.addNewDirectory(d);
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);
652 private updateDirectoriesParentNode(dir: CephfsDir) {
653 const parent = dir.parent;
657 const node = this.getNode(parent);
659 // Node will not be found for new sub sub directories - this is the intended behaviour
662 const children = this.getChildren(parent);
663 node.data.children = children;
664 node.data.hasChildren = children.length > 0;
665 this.treeComponent.treeModel.update();
668 private addNewDirectory(newDir: CephfsDir) {
669 this.dirs.push(newDir);
670 this.nodeIds[newDir.path] = newDir;
671 this.updateDirectoriesParentNode(newDir);
674 private updateExistingDirectory(source: CephfsDir[], updatedDir: CephfsDir) {
675 const currentDirObject = source.find((sub) => sub.path === updatedDir.path);
676 Object.assign(currentDirObject, updatedDir);
679 private updateQuotaTable() {
680 const node = this.selectedDir ? this.getNode(this.selectedDir.path) : undefined;
681 if (node && node.id !== '/') {
682 this.setSettings(node);
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.
692 this.treeComponent.treeModel.update();
693 this.nodes = [...this.nodes];
694 this.treeComponent.sizeChanged();
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()
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}'`
716 this.modalRef.close();
717 this.forceDirRefresh();
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);
733 unsetLoadingIndicator() {
734 if (!this.loadingIndicator) {
737 clearTimeout(this.loadingTimeout);
738 this.loadingTimeout = setTimeout(() => {
739 const loading = Object.values(this.loading).some((l) => l);
741 return this.unsetLoadingIndicator();
743 this.loadingIndicator = false;
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