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 _ from 'lodash';
12 import moment from 'moment';
14 import { CephfsService } from '~/app/shared/api/cephfs.service';
15 import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
16 import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
17 import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component';
18 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
19 import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
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 { ModalCdsService } from '~/app/shared/services/modal-cds.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 dirs: CephfsDir[];
69 private nodeIds: { [path: string]: CephfsDir };
70 private requestedPaths: string[];
71 private loadingTimeout: any;
74 loadingIndicator = false;
76 treeOptions: ITreeOptions = {
77 useVirtualScroll: true,
78 getChildren: (node: TreeNode): Promise<any[]> => {
79 return this.updateDirectory(node.id);
83 click: this.selectAndShowNode.bind(this),
84 expanderClick: this.selectAndShowNode.bind(this)
89 permission: Permission;
90 selectedDir: CephfsDir;
91 settings: QuotaSetting[];
93 columns: CdTableColumn[];
94 selection: CdTableSelection;
95 tableActions: CdTableAction[];
96 updateSelection: Function;
99 columns: CdTableColumn[];
100 selection: CdTableSelection;
101 tableActions: CdTableAction[];
102 updateSelection: Function;
105 alreadyExists: boolean;
108 private authStorageService: AuthStorageService,
109 private modalService: ModalCdsService,
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`,
203 cellTransformation: CellTemplate.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(),
223 disable: () => this.disableCreateSnapshot()
226 name: this.actionLabels.DELETE,
228 permission: 'delete',
229 click: () => this.deleteSnapshotModal(),
230 canBePrimary: (selection) => selection.hasSelection,
231 disable: (selection) => !selection.hasSelection
237 private disableCreateSnapshot(): string | boolean {
238 const folders = this.selectedDir.path.split('/').slice(1);
239 // With depth of 4 or more we have the subvolume files/folders for which we cannot create
240 // a snapshot. Somehow, you can create a snapshot of the subvolume but not its files.
241 if (folders.length >= 4 && folders[0] === 'volumes') {
242 return $localize`Cannot create snapshots for files/folders in the subvolume ${folders[2]}`;
248 this.selectedDir = undefined;
250 this.requestedPaths = [];
258 private setRootNode() {
268 private firstCall() {
271 this.getNode(path).loadNodeChildren();
275 updateDirectory(path: string): Promise<any[]> {
276 this.unsetLoadingIndicator();
277 if (!this.requestedPaths.includes(path)) {
278 this.requestedPaths.push(path);
279 } else if (this.loading[path] === true) {
280 return undefined; // Path is currently fetched.
282 return new Promise((resolve) => {
283 this.setLoadingIndicator(path, true);
284 this.cephfsService.lsDir(this.id, path).subscribe((dirs) => {
285 this.updateTreeStructure(dirs);
286 this.updateQuotaTable();
288 resolve(this.getChildren(path));
289 this.setLoadingIndicator(path, false);
291 if (path === '/' && this.treeComponent.treeModel.activeNodes?.length === 0) {
292 this.selectNode(this.getNode('/'));
298 private setLoadingIndicator(path: string, loading: boolean) {
299 this.loading[path] = loading;
300 this.unsetLoadingIndicator();
303 private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
304 return tree.filter((d) => d.parent === path);
307 private getChildren(path: string): any[] {
308 const subTree = this.getSubTree(path);
309 return _.sortBy(this.getSubDirectories(path), 'path').map((dir) =>
310 this.createNode(dir, subTree)
314 private createNode(dir: CephfsDir, subTree?: CephfsDir[]): any {
315 this.nodeIds[dir.path] = dir;
317 this.getSubTree(dir.parent);
320 if (dir.path === '/volumes') {
321 const innerNode = this.treeComponent.treeModel.getNodeById('/volumes');
329 hasChildren: this.getSubDirectories(dir.path, subTree).length > 0
333 private getSubTree(path: string): CephfsDir[] {
334 return this.dirs.filter((d) => d.parent && d.parent.startsWith(path));
337 private setSettings(node: TreeNode) {
338 const readable = (value: number, fn?: (arg0: number) => number | string): number | string =>
339 value ? (fn ? fn(value) : value) : '';
342 this.getQuota(node, 'max_files', readable),
343 this.getQuota(node, 'max_bytes', (value) =>
344 readable(value, (v) => this.dimlessBinaryPipe.transform(v))
352 valueConvertFn: (number: number) => number | string
354 // Get current maximum
355 const currentPath = tree.id;
356 tree = this.getOrigin(tree, quotaKey);
357 const dir = this.getDirectory(tree);
358 const value = dir.quotas[quotaKey];
359 // Get next tree maximum
360 // => The value that isn't changeable through a change of the current directories quota value
361 let nextMaxValue = value;
362 let nextMaxPath = dir.path;
363 if (tree.id === currentPath) {
364 if (tree.parent.id === '/') {
365 // The value will never inherit any other value, so it has no maximum.
368 const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey));
369 nextMaxValue = nextMaxDir.quotas[quotaKey];
370 nextMaxPath = nextMaxDir.path;
375 name: quotaKey === 'max_bytes' ? $localize`Max size` : $localize`Max files`,
376 value: valueConvertFn(value),
377 originPath: value ? dir.path : ''
380 dirValue: this.nodeIds[currentPath].quotas[quotaKey],
383 path: nextMaxValue ? nextMaxPath : ''
389 * Get the node where the quota limit originates from in the current node
391 * Example as it's a recursive method:
393 * | Path + Value | Call depth | useOrigin? | Output |
394 * |:-------------:|:----------:|:---------------------:|:------:|
395 * | /a/b/c/d (15) | 1st | 2nd (5) < 15 => false | /a/b |
396 * | /a/b/c (20) | 2nd | 3rd (5) < 20 => false | /a/b |
397 * | /a/b (5) | 3rd | 4th (10) < 5 => true | /a/b |
398 * | /a (10) | 4th | 10 => true | /a |
401 private getOrigin(tree: TreeNode, quotaSetting: string): TreeNode {
402 if (tree.parent && tree.parent.id !== '/') {
403 const current = this.getQuotaFromTree(tree, quotaSetting);
405 // Get the next used quota and node above the current one (until it hits the root directory)
406 const originTree = this.getOrigin(tree.parent, quotaSetting);
407 const inherited = this.getQuotaFromTree(originTree, quotaSetting);
409 // Select if the current quota is in use or the above
410 const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
411 return useOrigin ? originTree : tree;
416 private getQuotaFromTree(tree: TreeNode, quotaSetting: string): number {
417 return this.getDirectory(tree).quotas[quotaSetting];
420 private getDirectory(node: TreeNode): CephfsDir {
421 const path = node.id as string;
422 return this.nodeIds[path];
425 selectOrigin(path: string) {
426 this.selectNode(this.getNode(path));
429 private getNode(path: string): TreeNode {
430 return this.treeComponent.treeModel.getNodeById(path);
434 const path = this.selectedDir.path;
435 const selection: QuotaSetting = this.quota.selection.first();
436 const nextMax = selection.nextTreeMaximum;
437 const key = selection.quotaKey;
438 const value = selection.dirValue;
439 this.modalService.show(FormModalComponent, {
440 titleText: this.getModalQuotaTitle(
441 value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
444 message: nextMax.value
445 ? $localize`The inherited ${this.getQuotaValueFromPathMsg(
448 )} is the maximum value to be used.`
450 fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
451 submitButtonText: $localize`Save`,
452 onSubmit: (values: CephfsQuotas) => this.updateQuota(values)
456 private getModalQuotaTitle(action: string, path: string): string {
457 return $localize`${action} CephFS ${this.getQuotaName()} quota for '${path}'`;
460 private getQuotaName(): string {
461 return this.isBytesQuotaSelected() ? $localize`size` : $localize`files`;
464 private isBytesQuotaSelected(): boolean {
465 return this.quota.selection.first().quotaKey === 'max_bytes';
468 private getQuotaValueFromPathMsg(value: number, path: string): string {
469 value = this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value;
471 return $localize`${this.getQuotaName()} quota ${value} from '${path}'`;
474 private getQuotaFormField(
479 ): CdFormModalFieldConfig {
480 const isBinary = name === 'max_bytes';
481 const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
483 formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
485 const field: CdFormModalFieldConfig = {
486 type: isBinary ? 'binary' : 'number',
490 validators: formValidators,
495 min: $localize`Value has to be at least 0 or more`,
496 max: $localize`Value has to be at most ${maxValue} or less`
502 private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
503 const path = this.selectedDir.path;
504 const key = this.quota.selection.first().quotaKey;
506 this.selectedDir.quotas[key] === 0
507 ? this.actionLabels.SET
509 ? this.actionLabels.UNSET
510 : $localize`Updated`;
511 this.cephfsService.quota(this.id, path, values).subscribe(() => {
515 this.notificationService.show(
516 NotificationType.success,
517 this.getModalQuotaTitle(action, path)
519 this.forceDirRefresh();
524 const path = this.selectedDir.path;
525 const selection: QuotaSetting = this.quota.selection.first();
526 const key = selection.quotaKey;
527 const nextMax = selection.nextTreeMaximum;
528 const dirValue = selection.dirValue;
530 const quotaValue = this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path);
533 ? nextMax.value > dirValue
534 ? $localize`in order to inherit ${quotaValue}`
535 : $localize`which isn't used because of the inheritance of ${quotaValue}`
536 : $localize`in order to have no quota on the directory`;
538 this.modalService.show(ConfirmationModalComponent, {
539 titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
540 buttonText: this.actionLabels.UNSET,
541 description: $localize`${this.actionLabels.UNSET} ${this.getQuotaValueFromPathMsg(
545 onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalService.dismissAll())
550 // Create a snapshot. Auto-generate a snapshot name by default.
551 const path = this.selectedDir.path;
552 this.modalService.show(FormModalComponent, {
553 titleText: $localize`Create Snapshot`,
554 message: $localize`Please enter the name of the snapshot.`,
559 value: `${moment().toISOString(true)}`,
561 validators: [this.validateValue.bind(this)]
564 submitButtonText: $localize`Create Snapshot`,
565 onSubmit: (values: CephfsSnapshot) => {
566 if (!this.alreadyExists) {
567 this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
568 this.notificationService.show(
569 NotificationType.success,
570 $localize`Created snapshot '${name}' for '${path}'`
572 this.forceDirRefresh();
575 this.notificationService.show(
576 NotificationType.error,
577 $localize`Snapshot name '${values.name}' is already in use. Please use another name.`
584 validateValue(control: AbstractControl) {
585 this.alreadyExists = this.selectedDir.snapshots.some((s) => s.name === control.value);
589 * Forces an update of the current selected directory
591 * As all nodes point by their path on an directory object, the easiest way is to update
592 * the objects by merge with their latest change.
594 private forceDirRefresh(path?: string) {
596 const dir = this.selectedDir;
598 throw new Error('This function can only be called without path if an selection was made');
600 // Parent has to be called in order to update the object referring
601 // to the current selected directory
602 path = dir.parent ? dir.parent : dir.path;
604 const node = this.getNode(path);
605 node.loadNodeChildren();
608 private updateTreeStructure(dirs: CephfsDir[]) {
609 const getChildrenAndPaths = (
610 directories: CephfsDir[],
612 ): { children: CephfsDir[]; paths: string[] } => {
613 const children = directories.filter((d) => d.parent === parent);
614 const paths = children.map((d) => d.path);
615 return { children, paths };
618 const parents = _.uniq(dirs.map((d) => d.parent).sort());
619 parents.forEach((p) => {
620 const received = getChildrenAndPaths(dirs, p);
621 const cached = getChildrenAndPaths(this.dirs, p);
623 cached.children.forEach((d) => {
624 if (!received.paths.includes(d.path)) {
625 this.removeOldDirectory(d);
628 received.children.forEach((d) => {
629 if (cached.paths.includes(d.path)) {
630 this.updateExistingDirectory(cached.children, d);
632 this.addNewDirectory(d);
638 private removeOldDirectory(rmDir: CephfsDir) {
639 const path = rmDir.path;
640 // Remove directory from local variables
641 _.remove(this.dirs, (d) => d.path === path);
642 delete this.nodeIds[path];
643 this.updateDirectoriesParentNode(rmDir);
646 private updateDirectoriesParentNode(dir: CephfsDir) {
647 const parent = dir.parent;
651 const node = this.getNode(parent);
653 // Node will not be found for new sub directories - this is the intended behaviour
656 const children = this.getChildren(parent);
657 node.data.children = children;
658 node.data.hasChildren = children.length > 0;
659 this.treeComponent.treeModel.update();
662 private addNewDirectory(newDir: CephfsDir) {
663 this.dirs.push(newDir);
664 this.nodeIds[newDir.path] = newDir;
665 this.updateDirectoriesParentNode(newDir);
668 private updateExistingDirectory(source: CephfsDir[], updatedDir: CephfsDir) {
669 const currentDirObject = source.find((sub) => sub.path === updatedDir.path);
670 Object.assign(currentDirObject, updatedDir);
673 private updateQuotaTable() {
674 const node = this.selectedDir ? this.getNode(this.selectedDir.path) : undefined;
675 if (node && node.id !== '/') {
676 this.setSettings(node);
680 private updateTree(force: boolean = false) {
681 if (this.loadingIndicator && !force) {
682 // In order to make the page scrollable during load, the render cycle for each node
683 // is omitted and only be called if all updates were loaded.
686 this.treeComponent.treeModel.update();
687 this.nodes = [...this.nodes];
688 this.treeComponent.sizeChanged();
691 deleteSnapshotModal() {
692 this.modalService.show(CriticalConfirmationModalComponent, {
693 itemDescription: $localize`CephFs Snapshot`,
694 itemNames: this.snapshot.selection.selected.map((snapshot: CephfsSnapshot) => snapshot.name),
695 submitAction: () => this.deleteSnapshot()
700 const path = this.selectedDir.path;
701 this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => {
702 const name = snapshot.name;
703 this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => {
704 this.notificationService.show(
705 NotificationType.success,
706 $localize`Deleted snapshot '${name}' for '${path}'`
710 this.modalService.dismissAll();
711 this.forceDirRefresh();
714 refreshAllDirectories() {
715 // In order to make the page scrollable during load, the render cycle for each node
716 // is omitted and only be called if all updates were loaded.
717 this.loadingIndicator = true;
718 this.requestedPaths.map((path) => this.forceDirRefresh(path));
719 const interval = setInterval(() => {
720 this.updateTree(true);
721 if (!this.loadingIndicator) {
722 clearInterval(interval);
727 unsetLoadingIndicator() {
728 if (!this.loadingIndicator) {
731 clearTimeout(this.loadingTimeout);
732 this.loadingTimeout = setTimeout(() => {
733 const loading = Object.values(this.loading).some((l) => l);
735 return this.unsetLoadingIndicator();
737 this.loadingIndicator = false;
739 // The problem is that we can't subscribe to an useful updated tree event and the time
740 // between fetching all calls and rebuilding the tree can take some time