1 import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
2 import { AbstractControl, Validators } from '@angular/forms';
4 import { TreeViewComponent } from 'carbon-components-angular';
5 import { Node } from 'carbon-components-angular/treeview/tree-node.types';
6 import _ from 'lodash';
7 import moment from 'moment';
9 import { CephfsService } from '~/app/shared/api/cephfs.service';
10 import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component';
11 import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
12 import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component';
13 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
14 import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
15 import { DeletionImpact } from '~/app/shared/enum/critical-confirmation-modal-impact.enum';
16 import { Icons } from '~/app/shared/enum/icons.enum';
17 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
18 import { CdValidators } from '~/app/shared/forms/cd-validators';
19 import { CdFormModalFieldConfig } from '~/app/shared/models/cd-form-modal-field-config';
20 import { CdTableAction } from '~/app/shared/models/cd-table-action';
21 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
22 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
27 } from '~/app/shared/models/cephfs-directory-models';
28 import { Permission } from '~/app/shared/models/permissions';
29 import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
30 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
31 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
32 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
33 import { NotificationService } from '~/app/shared/services/notification.service';
34 import { TreeViewService } from '~/app/shared/services/tree-view.service';
38 // Used in quota table
40 value: number | string;
51 type TQuotaSettings = 'max_bytes' | 'max_files';
54 selector: 'cd-cephfs-directories',
55 templateUrl: './cephfs-directories.component.html',
56 styleUrls: ['./cephfs-directories.component.scss']
58 export class CephfsDirectoriesComponent implements OnInit, OnChanges {
59 @ViewChild(TreeViewComponent)
60 treeComponent: TreeViewComponent;
61 @ViewChild('origin', { static: true })
62 originTmpl: TemplateRef<any>;
67 private dirs: CephfsDir[];
68 private nodeIds: { [path: string]: CephfsDir };
69 private requestedPaths: string[];
70 private loadingTimeout: any;
73 loadingIndicator = false;
74 loading: Record<string, boolean> = {};
75 permission: Permission;
76 selectedDir: CephfsDir;
77 settings: QuotaSetting[];
79 columns: CdTableColumn[];
80 selection: CdTableSelection;
81 tableActions: CdTableAction[];
82 updateSelection: Function;
85 columns: CdTableColumn[];
86 selection: CdTableSelection;
87 tableActions: CdTableAction[];
88 updateSelection: Function;
91 alreadyExists: boolean;
94 private authStorageService: AuthStorageService,
95 private modalService: ModalCdsService,
96 private cephfsService: CephfsService,
97 private cdDatePipe: CdDatePipe,
98 private actionLabels: ActionLabelsI18n,
99 private notificationService: NotificationService,
100 private dimlessBinaryPipe: DimlessBinaryPipe,
101 private treeViewService: TreeViewService
104 async selectNode(node: Node) {
105 this.selectedDir = this.getDirectory(node);
106 if (node.id === '/') {
109 this.setSettings(node);
110 await this.updateDirectory(node.id);
111 this.nodes = this.treeViewService.expandNode(this.nodes, node);
115 this.permission = this.authStorageService.getPermissions().cephfs;
116 this.setUpQuotaTable();
117 this.setUpSnapshotTable();
120 private setUpQuotaTable() {
125 name: $localize`Name`,
130 name: $localize`Value`,
135 prop: 'row.originPath',
136 name: $localize`Origin`,
138 cellTemplate: this.originTmpl,
142 selection: new CdTableSelection(),
143 updateSelection: (selection: CdTableSelection) => {
144 this.quota.selection = selection;
148 name: this.actionLabels.SET,
150 permission: 'update',
151 visible: (selection) =>
152 !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
153 click: () => this.updateQuotaModal()
156 name: this.actionLabels.UPDATE,
158 permission: 'update',
159 visible: (selection) => selection.first() && selection.first().dirValue > 0,
160 click: () => this.updateQuotaModal()
163 name: this.actionLabels.UNSET,
165 permission: 'update',
166 disable: (selection) =>
167 !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
168 click: () => this.unsetQuotaModal()
174 private setUpSnapshotTable() {
179 name: $localize`Name`,
184 name: $localize`Path`,
186 cellTransformation: CellTemplate.path
190 name: $localize`Created`,
192 pipe: this.cdDatePipe
195 selection: new CdTableSelection(),
196 updateSelection: (selection: CdTableSelection) => {
197 this.snapshot.selection = selection;
201 name: this.actionLabels.CREATE,
203 permission: 'create',
204 canBePrimary: (selection) => !selection.hasSelection,
205 click: () => this.createSnapshot(),
206 disable: () => this.disableCreateSnapshot()
209 name: this.actionLabels.DELETE,
211 permission: 'delete',
212 click: () => this.deleteSnapshotModal()
218 private disableCreateSnapshot(): string | boolean {
219 const folders = this.selectedDir.path.split('/').slice(1);
220 // With depth of 4 or more we have the subvolume files/folders for which we cannot create
221 // a snapshot. Somehow, you can create a snapshot of the subvolume but not its files.
222 if (folders.length >= 4 && folders[0] === 'volumes') {
223 return $localize`Cannot create snapshots for files/folders in the subvolume ${folders[2]}`;
229 this.selectedDir = undefined;
231 this.requestedPaths = [];
239 private setRootNode() {
250 private firstCall() {
252 setTimeout(async () => {
253 await this.updateDirectory(path);
257 updateDirectory(path: string): Promise<Node[]> {
258 this.unsetLoadingIndicator();
259 if (!this.requestedPaths.includes(path)) {
260 this.requestedPaths.push(path);
261 } else if (this.loading[path] === true) {
262 return undefined; // Path is currently fetched.
264 return new Promise((resolve) => {
265 this.setLoadingIndicator(path, true);
266 this.cephfsService.lsDir(this.id, path).subscribe((dirs) => {
267 this.updateTreeStructure(dirs);
268 this.updateQuotaTable();
270 resolve(this.getChildren(path));
271 this.setLoadingIndicator(path, false);
273 const hasActiveNodes = !!this.treeViewService.findNode(true, this.nodes, 'active');
274 if (path === '/' && !hasActiveNodes) {
275 this.treeComponent.select.emit(this.getNode('/'));
281 private setLoadingIndicator(path: string, loading: boolean) {
282 this.loading[path] = loading;
283 this.unsetLoadingIndicator();
286 private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
287 return tree.filter((d) => d.parent === path);
290 private getChildren(path: string): Node[] {
291 const subTree = this.getSubTree(path);
292 return _.sortBy(this.getSubDirectories(path), 'path').map((dir) =>
293 this.createNode(dir, subTree)
297 private createNode(dir: CephfsDir, subTree?: CephfsDir[]): Node {
298 this.nodeIds[dir.path] = dir;
300 this.getSubTree(dir.parent);
303 if (dir.path === '/volumes') {
304 const innerNode = this.treeViewService.findNode('/volumes', this.nodes);
306 this.treeComponent.select.emit(innerNode);
313 expanded: dir.path === '/volumes',
314 children: this.getSubDirectories(dir.path, subTree).map(this.toNode),
321 private getSubTree(path: string): CephfsDir[] {
322 return this.dirs.filter((d) => d.parent && d.parent.startsWith(path));
325 private setSettings(node: Node) {
326 const readable = (value: number, fn?: (arg0: number) => number | string): number | string =>
327 value ? (fn ? fn(value) : value) : '';
330 this.getQuota(node, 'max_files', readable),
331 this.getQuota(node, 'max_bytes', (value) =>
332 readable(value, (v) => this.dimlessBinaryPipe.transform(v))
339 quotaKey: TQuotaSettings,
340 valueConvertFn: (number: number) => number | string
342 // Get current maximum
343 const currentPath = tree.id;
344 tree = this.getOrigin(tree, quotaKey);
345 const dir = this.getDirectory(tree);
346 const value = dir.quotas[quotaKey];
347 // Get next tree maximum
348 // => The value that isn't changeable through a change of the current directories quota value
349 let nextMaxValue = value;
350 let nextMaxPath = dir.path;
351 if (tree.id === currentPath) {
352 if (tree.value?.parent === '/') {
353 // The value will never inherit any other value, so it has no maximum.
356 const parent = this.getParent(this.dirs, tree.value?.parent);
358 const nextMaxDir = this.getDirectory(this.getOrigin(parent, quotaKey));
359 nextMaxValue = nextMaxDir.quotas[quotaKey];
360 nextMaxPath = nextMaxDir.path;
366 name: quotaKey === 'max_bytes' ? $localize`Max size` : $localize`Max files`,
367 value: valueConvertFn(value),
368 originPath: value ? dir.path : ''
371 dirValue: this.nodeIds[currentPath].quotas[quotaKey],
374 path: nextMaxValue ? nextMaxPath : ''
380 * Get the node where the quota limit originates from in the current node
382 * Example as it's a recursive method:
384 * | Path + Value | Call depth | useOrigin? | Output |
385 * |:-------------:|:----------:|:---------------------:|:------:|
386 * | /a/b/c/d (15) | 1st | 2nd (5) < 15 => false | /a/b |
387 * | /a/b/c (20) | 2nd | 3rd (5) < 20 => false | /a/b |
388 * | /a/b (5) | 3rd | 4th (10) < 5 => true | /a/b |
389 * | /a (10) | 4th | 10 => true | /a |
392 private getOrigin(tree: Node, quotaSetting: TQuotaSettings): Node {
393 const parent = this.getParent(this.dirs, tree.value?.parent);
394 if (parent && parent?.id !== '/') {
395 const current = this.getQuotaFromTree(tree, quotaSetting);
397 // Get the next used quota and node above the current one (until it hits the root directory)
398 const originTree = this.getOrigin(parent, quotaSetting);
399 const inherited = this.getQuotaFromTree(originTree, quotaSetting);
401 // Select if the current quota is in use or the above
402 const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
403 return useOrigin ? originTree : tree;
408 private getQuotaFromTree(tree: Node, quotaSetting: TQuotaSettings): number {
409 return this.getDirectory(tree).quotas[quotaSetting];
412 private getDirectory(node: Node): CephfsDir {
413 const path = node.id as string;
414 return this.nodeIds[path];
417 selectOrigin(path: string) {
418 this.treeComponent.select.emit(this.getNode(path));
421 private getNode(path: string): Node {
422 return this.treeViewService.findNode(path, this.nodes);
426 const path = this.selectedDir.path;
427 const selection: QuotaSetting = this.quota.selection.first();
428 const nextMax = selection.nextTreeMaximum;
429 const key = selection.quotaKey;
430 const value = selection.dirValue;
431 this.modalService.show(FormModalComponent, {
432 titleText: this.getModalQuotaTitle(
433 value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
436 message: nextMax.value
437 ? $localize`The inherited ${this.getQuotaValueFromPathMsg(
440 )} is the maximum value to be used.`
442 fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
443 submitButtonText: $localize`Save`,
444 onSubmit: (values: CephfsQuotas) => this.updateQuota(values)
448 private getModalQuotaTitle(action: string, path: string): string {
449 return $localize`${action} CephFS ${this.getQuotaName()} quota for '${path}'`;
452 private getQuotaName(): string {
453 return this.isBytesQuotaSelected() ? $localize`size` : $localize`files`;
456 private isBytesQuotaSelected(): boolean {
457 return this.quota.selection.first().quotaKey === 'max_bytes';
460 private getQuotaValueFromPathMsg(value: number, path: string): string {
461 value = this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value;
463 return $localize`${this.getQuotaName()} quota ${value} from '${path}'`;
466 private getQuotaFormField(
471 ): CdFormModalFieldConfig {
472 const isBinary = name === 'max_bytes';
473 const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
475 formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
477 const field: CdFormModalFieldConfig = {
478 type: isBinary ? 'binary' : 'number',
482 validators: formValidators,
487 min: $localize`Value has to be at least 0 or more`,
488 max: $localize`Value has to be at most ${maxValue} or less`
494 private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
495 const path = this.selectedDir.path;
496 const key: TQuotaSettings = this.quota.selection.first().quotaKey;
498 this.selectedDir.quotas[key] === 0
499 ? this.actionLabels.SET
501 ? this.actionLabels.UNSET
502 : $localize`Updated`;
503 this.cephfsService.quota(this.id, path, values).subscribe(() => {
507 this.notificationService.show(
508 NotificationType.success,
509 this.getModalQuotaTitle(action, path)
511 this.forceDirRefresh();
516 const path = this.selectedDir.path;
517 const selection: QuotaSetting = this.quota.selection.first();
518 const key = selection.quotaKey;
519 const nextMax = selection.nextTreeMaximum;
520 const dirValue = selection.dirValue;
522 const quotaValue = this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path);
525 ? nextMax.value > dirValue
526 ? $localize`in order to inherit ${quotaValue}`
527 : $localize`which isn't used because of the inheritance of ${quotaValue}`
528 : $localize`in order to have no quota on the directory`;
530 this.modalService.show(ConfirmationModalComponent, {
531 titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
532 buttonText: this.actionLabels.UNSET,
533 description: $localize`${this.actionLabels.UNSET} ${this.getQuotaValueFromPathMsg(
537 onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalService.dismissAll())
542 // Create a snapshot. Auto-generate a snapshot name by default.
543 const path = this.selectedDir.path;
544 this.modalService.show(FormModalComponent, {
545 titleText: $localize`Create Snapshot`,
546 message: $localize`Please enter the name of the snapshot.`,
551 value: `${moment().toISOString(true)}`,
553 validators: [this.validateValue.bind(this)]
556 submitButtonText: $localize`Create Snapshot`,
557 onSubmit: (values: CephfsSnapshot) => {
558 if (!this.alreadyExists) {
559 this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
560 this.notificationService.show(
561 NotificationType.success,
562 $localize`Created snapshot '${name}' for '${path}'`
564 this.forceDirRefresh();
567 this.notificationService.show(
568 NotificationType.error,
569 $localize`Snapshot name '${values.name}' is already in use. Please use another name.`
576 validateValue(control: AbstractControl) {
577 this.alreadyExists = this.selectedDir.snapshots.some((s) => s.name === control.value);
581 * Forces an update of the current selected directory
583 * As all nodes point by their path on an directory object, the easiest way is to update
584 * the objects by merge with their latest change.
586 private forceDirRefresh(path?: string) {
588 const dir = this.selectedDir;
590 throw new Error('This function can only be called without path if an selection was made');
592 // Parent has to be called in order to update the object referring
593 // to the current selected directory
594 path = dir.parent ? dir.parent : dir.path;
595 const node = this.getNode(path);
596 this.treeComponent.select.emit(node);
597 const selectedNode = this.getNode(dir.path);
598 this.treeComponent.select.emit(selectedNode);
601 const node = this.getNode(path);
602 this.treeComponent.select.emit(node);
605 private updateTreeStructure(dirs: CephfsDir[]) {
606 const getChildrenAndPaths = (
607 directories: CephfsDir[],
609 ): { children: CephfsDir[]; paths: string[] } => {
610 const children = directories.filter((d) => d.parent === parent);
611 const paths = children.map((d) => d.path);
612 return { children, paths };
615 const parents = _.uniq(dirs.map((d) => d.parent).sort());
616 parents.forEach((p) => {
617 const received = getChildrenAndPaths(dirs, p);
618 const cached = getChildrenAndPaths(this.dirs, p);
620 cached.children.forEach((d) => {
621 if (!received.paths.includes(d.path)) {
622 this.removeOldDirectory(d);
625 received.children.forEach((d) => {
626 if (cached.paths.includes(d.path)) {
627 this.updateExistingDirectory(cached.children, d);
629 this.addNewDirectory(d);
635 private removeOldDirectory(rmDir: CephfsDir) {
636 const path = rmDir.path;
637 // Remove directory from local variables
638 _.remove(this.dirs, (d) => d.path === path);
639 delete this.nodeIds[path];
640 this.updateDirectoriesParentNode(rmDir);
643 private updateDirectoriesParentNode(dir: CephfsDir) {
644 const parent = dir.parent;
648 const node = this.getNode(parent);
650 // Node will not be found for new sub directories - this is the intended behaviour
653 const children = this.getChildren(parent);
654 node.children = children;
657 private addNewDirectory(newDir: CephfsDir) {
658 this.dirs.push(newDir);
659 this.nodeIds[newDir.path] = newDir;
660 this.updateDirectoriesParentNode(newDir);
663 private updateExistingDirectory(source: CephfsDir[], updatedDir: CephfsDir) {
664 const currentDirObject = source.find((sub) => sub.path === updatedDir.path);
665 Object.assign(currentDirObject, updatedDir);
668 private updateQuotaTable() {
669 const node = this.selectedDir ? this.getNode(this.selectedDir.path) : undefined;
670 if (node && node.id !== '/') {
671 this.setSettings(node);
675 private updateTree(force: boolean = false) {
676 if (this.loadingIndicator && !force) {
677 // In order to make the page scrollable during load, the render cycle for each node
678 // is omitted and only be called if all updates were loaded.
681 this.nodes = [...this.nodes];
684 deleteSnapshotModal() {
685 this.modalService.show(CriticalConfirmationModalComponent, {
686 impact: DeletionImpact.high,
687 itemDescription: $localize`CephFs Snapshot`,
688 itemNames: this.snapshot.selection.selected.map((snapshot: CephfsSnapshot) => snapshot.name),
689 submitAction: () => this.deleteSnapshot()
694 const path = this.selectedDir.path;
695 this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => {
696 const name = snapshot.name;
697 this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => {
698 this.notificationService.show(
699 NotificationType.success,
700 $localize`Deleted snapshot '${name}' for '${path}'`
704 this.modalService.dismissAll();
705 this.forceDirRefresh();
708 refreshAllDirectories() {
709 // In order to make the page scrollable during load, the render cycle for each node
710 // is omitted and only be called if all updates were loaded.
711 this.loadingIndicator = true;
712 this.requestedPaths.map((path) => this.forceDirRefresh(path));
713 const interval = setInterval(() => {
714 this.updateTree(true);
715 if (!this.loadingIndicator) {
716 clearInterval(interval);
721 unsetLoadingIndicator() {
722 if (!this.loadingIndicator) {
725 clearTimeout(this.loadingTimeout);
726 this.loadingTimeout = setTimeout(() => {
727 const loading = Object.values(this.loading).some((l) => l);
729 return this.unsetLoadingIndicator();
731 this.loadingIndicator = false;
733 // The problem is that we can't subscribe to an useful updated tree event and the time
734 // between fetching all calls and rebuilding the tree can take some time
739 * Converts a CephfsDir object to Node type
740 * @param directory CephfsDir object
741 * @returns Converted Node object
743 toNode(directory: CephfsDir): Node {
746 label: directory.name,
749 value: { parent: directory?.parent }
754 * Get parent node for a given CephfsDir directory
755 * @param dirs CephfsDir directories array
756 * @param path Parent path
757 * @returns Parent node
759 getParent(dirs: CephfsDir[], path: string): Node {
760 const parentNode = dirs?.find?.((dir: CephfsDir) => dir.path === path);
761 return parentNode ? this.toNode(parentNode) : null;