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 { Icons } from '~/app/shared/enum/icons.enum';
16 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
17 import { CdValidators } from '~/app/shared/forms/cd-validators';
18 import { CdFormModalFieldConfig } from '~/app/shared/models/cd-form-modal-field-config';
19 import { CdTableAction } from '~/app/shared/models/cd-table-action';
20 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
21 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
26 } from '~/app/shared/models/cephfs-directory-models';
27 import { Permission } from '~/app/shared/models/permissions';
28 import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
29 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
30 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
31 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
32 import { NotificationService } from '~/app/shared/services/notification.service';
33 import { TreeViewService } from '~/app/shared/services/tree-view.service';
37 // Used in quota table
39 value: number | string;
50 type TQuotaSettings = 'max_bytes' | 'max_files';
53 selector: 'cd-cephfs-directories',
54 templateUrl: './cephfs-directories.component.html',
55 styleUrls: ['./cephfs-directories.component.scss']
57 export class CephfsDirectoriesComponent implements OnInit, OnChanges {
58 @ViewChild(TreeViewComponent)
59 treeComponent: TreeViewComponent;
60 @ViewChild('origin', { static: true })
61 originTmpl: TemplateRef<any>;
66 private dirs: CephfsDir[];
67 private nodeIds: { [path: string]: CephfsDir };
68 private requestedPaths: string[];
69 private loadingTimeout: any;
72 loadingIndicator = false;
73 loading: Record<string, boolean> = {};
74 permission: Permission;
75 selectedDir: CephfsDir;
76 settings: QuotaSetting[];
78 columns: CdTableColumn[];
79 selection: CdTableSelection;
80 tableActions: CdTableAction[];
81 updateSelection: Function;
84 columns: CdTableColumn[];
85 selection: CdTableSelection;
86 tableActions: CdTableAction[];
87 updateSelection: Function;
90 alreadyExists: boolean;
93 private authStorageService: AuthStorageService,
94 private modalService: ModalCdsService,
95 private cephfsService: CephfsService,
96 private cdDatePipe: CdDatePipe,
97 private actionLabels: ActionLabelsI18n,
98 private notificationService: NotificationService,
99 private dimlessBinaryPipe: DimlessBinaryPipe,
100 private treeViewService: TreeViewService
103 async selectNode(node: Node) {
104 this.selectedDir = this.getDirectory(node);
105 if (node.id === '/') {
108 this.setSettings(node);
109 await this.updateDirectory(node.id);
110 this.nodes = this.treeViewService.expandNode(this.nodes, node);
114 this.permission = this.authStorageService.getPermissions().cephfs;
115 this.setUpQuotaTable();
116 this.setUpSnapshotTable();
119 private setUpQuotaTable() {
124 name: $localize`Name`,
129 name: $localize`Value`,
134 prop: 'row.originPath',
135 name: $localize`Origin`,
137 cellTemplate: this.originTmpl,
141 selection: new CdTableSelection(),
142 updateSelection: (selection: CdTableSelection) => {
143 this.quota.selection = selection;
147 name: this.actionLabels.SET,
149 permission: 'update',
150 visible: (selection) =>
151 !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
152 click: () => this.updateQuotaModal()
155 name: this.actionLabels.UPDATE,
157 permission: 'update',
158 visible: (selection) => selection.first() && selection.first().dirValue > 0,
159 click: () => this.updateQuotaModal()
162 name: this.actionLabels.UNSET,
164 permission: 'update',
165 disable: (selection) =>
166 !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
167 click: () => this.unsetQuotaModal()
173 private setUpSnapshotTable() {
178 name: $localize`Name`,
183 name: $localize`Path`,
185 cellTransformation: CellTemplate.path
189 name: $localize`Created`,
191 pipe: this.cdDatePipe
194 selection: new CdTableSelection(),
195 updateSelection: (selection: CdTableSelection) => {
196 this.snapshot.selection = selection;
200 name: this.actionLabels.CREATE,
202 permission: 'create',
203 canBePrimary: (selection) => !selection.hasSelection,
204 click: () => this.createSnapshot(),
205 disable: () => this.disableCreateSnapshot()
208 name: this.actionLabels.DELETE,
210 permission: 'delete',
211 click: () => this.deleteSnapshotModal(),
212 canBePrimary: (selection) => selection.hasSelection,
213 disable: (selection) => !selection.hasSelection
219 private disableCreateSnapshot(): string | boolean {
220 const folders = this.selectedDir.path.split('/').slice(1);
221 // With depth of 4 or more we have the subvolume files/folders for which we cannot create
222 // a snapshot. Somehow, you can create a snapshot of the subvolume but not its files.
223 if (folders.length >= 4 && folders[0] === 'volumes') {
224 return $localize`Cannot create snapshots for files/folders in the subvolume ${folders[2]}`;
230 this.selectedDir = undefined;
232 this.requestedPaths = [];
240 private setRootNode() {
251 private firstCall() {
253 setTimeout(async () => {
254 await this.updateDirectory(path);
258 updateDirectory(path: string): Promise<Node[]> {
259 this.unsetLoadingIndicator();
260 if (!this.requestedPaths.includes(path)) {
261 this.requestedPaths.push(path);
262 } else if (this.loading[path] === true) {
263 return undefined; // Path is currently fetched.
265 return new Promise((resolve) => {
266 this.setLoadingIndicator(path, true);
267 this.cephfsService.lsDir(this.id, path).subscribe((dirs) => {
268 this.updateTreeStructure(dirs);
269 this.updateQuotaTable();
271 resolve(this.getChildren(path));
272 this.setLoadingIndicator(path, false);
274 const hasActiveNodes = !!this.treeViewService.findNode(true, this.nodes, 'active');
275 if (path === '/' && !hasActiveNodes) {
276 this.treeComponent.select.emit(this.getNode('/'));
282 private setLoadingIndicator(path: string, loading: boolean) {
283 this.loading[path] = loading;
284 this.unsetLoadingIndicator();
287 private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
288 return tree.filter((d) => d.parent === path);
291 private getChildren(path: string): Node[] {
292 const subTree = this.getSubTree(path);
293 return _.sortBy(this.getSubDirectories(path), 'path').map((dir) =>
294 this.createNode(dir, subTree)
298 private createNode(dir: CephfsDir, subTree?: CephfsDir[]): Node {
299 this.nodeIds[dir.path] = dir;
301 this.getSubTree(dir.parent);
304 if (dir.path === '/volumes') {
305 const innerNode = this.treeViewService.findNode('/volumes', this.nodes);
307 this.treeComponent.select.emit(innerNode);
314 expanded: dir.path === '/volumes',
315 children: this.getSubDirectories(dir.path, subTree).map(this.toNode),
322 private getSubTree(path: string): CephfsDir[] {
323 return this.dirs.filter((d) => d.parent && d.parent.startsWith(path));
326 private setSettings(node: Node) {
327 const readable = (value: number, fn?: (arg0: number) => number | string): number | string =>
328 value ? (fn ? fn(value) : value) : '';
331 this.getQuota(node, 'max_files', readable),
332 this.getQuota(node, 'max_bytes', (value) =>
333 readable(value, (v) => this.dimlessBinaryPipe.transform(v))
340 quotaKey: TQuotaSettings,
341 valueConvertFn: (number: number) => number | string
343 // Get current maximum
344 const currentPath = tree.id;
345 tree = this.getOrigin(tree, quotaKey);
346 const dir = this.getDirectory(tree);
347 const value = dir.quotas[quotaKey];
348 // Get next tree maximum
349 // => The value that isn't changeable through a change of the current directories quota value
350 let nextMaxValue = value;
351 let nextMaxPath = dir.path;
352 if (tree.id === currentPath) {
353 if (tree.value?.parent === '/') {
354 // The value will never inherit any other value, so it has no maximum.
357 const parent = this.getParent(this.dirs, tree.value?.parent);
359 const nextMaxDir = this.getDirectory(this.getOrigin(parent, quotaKey));
360 nextMaxValue = nextMaxDir.quotas[quotaKey];
361 nextMaxPath = nextMaxDir.path;
367 name: quotaKey === 'max_bytes' ? $localize`Max size` : $localize`Max files`,
368 value: valueConvertFn(value),
369 originPath: value ? dir.path : ''
372 dirValue: this.nodeIds[currentPath].quotas[quotaKey],
375 path: nextMaxValue ? nextMaxPath : ''
381 * Get the node where the quota limit originates from in the current node
383 * Example as it's a recursive method:
385 * | Path + Value | Call depth | useOrigin? | Output |
386 * |:-------------:|:----------:|:---------------------:|:------:|
387 * | /a/b/c/d (15) | 1st | 2nd (5) < 15 => false | /a/b |
388 * | /a/b/c (20) | 2nd | 3rd (5) < 20 => false | /a/b |
389 * | /a/b (5) | 3rd | 4th (10) < 5 => true | /a/b |
390 * | /a (10) | 4th | 10 => true | /a |
393 private getOrigin(tree: Node, quotaSetting: TQuotaSettings): Node {
394 const parent = this.getParent(this.dirs, tree.value?.parent);
395 if (parent && parent?.id !== '/') {
396 const current = this.getQuotaFromTree(tree, quotaSetting);
398 // Get the next used quota and node above the current one (until it hits the root directory)
399 const originTree = this.getOrigin(parent, quotaSetting);
400 const inherited = this.getQuotaFromTree(originTree, quotaSetting);
402 // Select if the current quota is in use or the above
403 const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
404 return useOrigin ? originTree : tree;
409 private getQuotaFromTree(tree: Node, quotaSetting: TQuotaSettings): number {
410 return this.getDirectory(tree).quotas[quotaSetting];
413 private getDirectory(node: Node): CephfsDir {
414 const path = node.id as string;
415 return this.nodeIds[path];
418 selectOrigin(path: string) {
419 this.treeComponent.select.emit(this.getNode(path));
422 private getNode(path: string): Node {
423 return this.treeViewService.findNode(path, this.nodes);
427 const path = this.selectedDir.path;
428 const selection: QuotaSetting = this.quota.selection.first();
429 const nextMax = selection.nextTreeMaximum;
430 const key = selection.quotaKey;
431 const value = selection.dirValue;
432 this.modalService.show(FormModalComponent, {
433 titleText: this.getModalQuotaTitle(
434 value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
437 message: nextMax.value
438 ? $localize`The inherited ${this.getQuotaValueFromPathMsg(
441 )} is the maximum value to be used.`
443 fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
444 submitButtonText: $localize`Save`,
445 onSubmit: (values: CephfsQuotas) => this.updateQuota(values)
449 private getModalQuotaTitle(action: string, path: string): string {
450 return $localize`${action} CephFS ${this.getQuotaName()} quota for '${path}'`;
453 private getQuotaName(): string {
454 return this.isBytesQuotaSelected() ? $localize`size` : $localize`files`;
457 private isBytesQuotaSelected(): boolean {
458 return this.quota.selection.first().quotaKey === 'max_bytes';
461 private getQuotaValueFromPathMsg(value: number, path: string): string {
462 value = this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value;
464 return $localize`${this.getQuotaName()} quota ${value} from '${path}'`;
467 private getQuotaFormField(
472 ): CdFormModalFieldConfig {
473 const isBinary = name === 'max_bytes';
474 const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
476 formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
478 const field: CdFormModalFieldConfig = {
479 type: isBinary ? 'binary' : 'number',
483 validators: formValidators,
488 min: $localize`Value has to be at least 0 or more`,
489 max: $localize`Value has to be at most ${maxValue} or less`
495 private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
496 const path = this.selectedDir.path;
497 const key: TQuotaSettings = this.quota.selection.first().quotaKey;
499 this.selectedDir.quotas[key] === 0
500 ? this.actionLabels.SET
502 ? this.actionLabels.UNSET
503 : $localize`Updated`;
504 this.cephfsService.quota(this.id, path, values).subscribe(() => {
508 this.notificationService.show(
509 NotificationType.success,
510 this.getModalQuotaTitle(action, path)
512 this.forceDirRefresh();
517 const path = this.selectedDir.path;
518 const selection: QuotaSetting = this.quota.selection.first();
519 const key = selection.quotaKey;
520 const nextMax = selection.nextTreeMaximum;
521 const dirValue = selection.dirValue;
523 const quotaValue = this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path);
526 ? nextMax.value > dirValue
527 ? $localize`in order to inherit ${quotaValue}`
528 : $localize`which isn't used because of the inheritance of ${quotaValue}`
529 : $localize`in order to have no quota on the directory`;
531 this.modalService.show(ConfirmationModalComponent, {
532 titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
533 buttonText: this.actionLabels.UNSET,
534 description: $localize`${this.actionLabels.UNSET} ${this.getQuotaValueFromPathMsg(
538 onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalService.dismissAll())
543 // Create a snapshot. Auto-generate a snapshot name by default.
544 const path = this.selectedDir.path;
545 this.modalService.show(FormModalComponent, {
546 titleText: $localize`Create Snapshot`,
547 message: $localize`Please enter the name of the snapshot.`,
552 value: `${moment().toISOString(true)}`,
554 validators: [this.validateValue.bind(this)]
557 submitButtonText: $localize`Create Snapshot`,
558 onSubmit: (values: CephfsSnapshot) => {
559 if (!this.alreadyExists) {
560 this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
561 this.notificationService.show(
562 NotificationType.success,
563 $localize`Created snapshot '${name}' for '${path}'`
565 this.forceDirRefresh();
568 this.notificationService.show(
569 NotificationType.error,
570 $localize`Snapshot name '${values.name}' is already in use. Please use another name.`
577 validateValue(control: AbstractControl) {
578 this.alreadyExists = this.selectedDir.snapshots.some((s) => s.name === control.value);
582 * Forces an update of the current selected directory
584 * As all nodes point by their path on an directory object, the easiest way is to update
585 * the objects by merge with their latest change.
587 private forceDirRefresh(path?: string) {
589 const dir = this.selectedDir;
591 throw new Error('This function can only be called without path if an selection was made');
593 // Parent has to be called in order to update the object referring
594 // to the current selected directory
595 path = dir.parent ? dir.parent : dir.path;
596 const node = this.getNode(path);
597 this.treeComponent.select.emit(node);
598 const selectedNode = this.getNode(dir.path);
599 this.treeComponent.select.emit(selectedNode);
602 const node = this.getNode(path);
603 this.treeComponent.select.emit(node);
606 private updateTreeStructure(dirs: CephfsDir[]) {
607 const getChildrenAndPaths = (
608 directories: CephfsDir[],
610 ): { children: CephfsDir[]; paths: string[] } => {
611 const children = directories.filter((d) => d.parent === parent);
612 const paths = children.map((d) => d.path);
613 return { children, paths };
616 const parents = _.uniq(dirs.map((d) => d.parent).sort());
617 parents.forEach((p) => {
618 const received = getChildrenAndPaths(dirs, p);
619 const cached = getChildrenAndPaths(this.dirs, p);
621 cached.children.forEach((d) => {
622 if (!received.paths.includes(d.path)) {
623 this.removeOldDirectory(d);
626 received.children.forEach((d) => {
627 if (cached.paths.includes(d.path)) {
628 this.updateExistingDirectory(cached.children, d);
630 this.addNewDirectory(d);
636 private removeOldDirectory(rmDir: CephfsDir) {
637 const path = rmDir.path;
638 // Remove directory from local variables
639 _.remove(this.dirs, (d) => d.path === path);
640 delete this.nodeIds[path];
641 this.updateDirectoriesParentNode(rmDir);
644 private updateDirectoriesParentNode(dir: CephfsDir) {
645 const parent = dir.parent;
649 const node = this.getNode(parent);
651 // Node will not be found for new sub directories - this is the intended behaviour
654 const children = this.getChildren(parent);
655 node.children = children;
658 private addNewDirectory(newDir: CephfsDir) {
659 this.dirs.push(newDir);
660 this.nodeIds[newDir.path] = newDir;
661 this.updateDirectoriesParentNode(newDir);
664 private updateExistingDirectory(source: CephfsDir[], updatedDir: CephfsDir) {
665 const currentDirObject = source.find((sub) => sub.path === updatedDir.path);
666 Object.assign(currentDirObject, updatedDir);
669 private updateQuotaTable() {
670 const node = this.selectedDir ? this.getNode(this.selectedDir.path) : undefined;
671 if (node && node.id !== '/') {
672 this.setSettings(node);
676 private updateTree(force: boolean = false) {
677 if (this.loadingIndicator && !force) {
678 // In order to make the page scrollable during load, the render cycle for each node
679 // is omitted and only be called if all updates were loaded.
682 this.nodes = [...this.nodes];
685 deleteSnapshotModal() {
686 this.modalService.show(CriticalConfirmationModalComponent, {
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;