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';
5 import { I18n } from '@ngx-translate/i18n-polyfill';
12 } from 'angular-tree-component';
13 import * as _ from 'lodash';
14 import * as moment from 'moment';
16 import { CephfsService } from '../../../shared/api/cephfs.service';
17 import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component';
18 import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
19 import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component';
20 import { ActionLabelsI18n } from '../../../shared/constants/app.constants';
21 import { Icons } from '../../../shared/enum/icons.enum';
22 import { NotificationType } from '../../../shared/enum/notification-type.enum';
23 import { CdValidators } from '../../../shared/forms/cd-validators';
24 import { CdFormModalFieldConfig } from '../../../shared/models/cd-form-modal-field-config';
25 import { CdTableAction } from '../../../shared/models/cd-table-action';
26 import { CdTableColumn } from '../../../shared/models/cd-table-column';
27 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
32 } from '../../../shared/models/cephfs-directory-models';
33 import { Permission } from '../../../shared/models/permissions';
34 import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
35 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
36 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
37 import { ModalService } from '../../../shared/services/modal.service';
38 import { NotificationService } from '../../../shared/services/notification.service';
42 // Used in quota table
44 value: number | string;
56 selector: 'cd-cephfs-directories',
57 templateUrl: './cephfs-directories.component.html',
58 styleUrls: ['./cephfs-directories.component.scss']
60 export class CephfsDirectoriesComponent implements OnInit, OnChanges {
61 @ViewChild(TreeComponent)
62 treeComponent: TreeComponent;
63 @ViewChild('origin', { static: true })
64 originTmpl: TemplateRef<any>;
69 private modalRef: NgbModalRef;
70 private dirs: CephfsDir[];
71 private nodeIds: { [path: string]: CephfsDir };
72 private requestedPaths: string[];
73 private loadingTimeout: any;
76 loadingIndicator = false;
78 treeOptions: ITreeOptions = {
79 useVirtualScroll: true,
80 getChildren: (node: TreeNode): Promise<any[]> => {
81 return this.updateDirectory(node.id);
85 click: this.selectAndShowNode.bind(this),
86 expanderClick: this.selectAndShowNode.bind(this)
91 permission: Permission;
92 selectedDir: CephfsDir;
93 settings: QuotaSetting[];
95 columns: CdTableColumn[];
96 selection: CdTableSelection;
97 tableActions: CdTableAction[];
98 updateSelection: Function;
101 columns: CdTableColumn[];
102 selection: CdTableSelection;
103 tableActions: CdTableAction[];
104 updateSelection: Function;
109 private authStorageService: AuthStorageService,
110 private modalService: ModalService,
111 private cephfsService: CephfsService,
112 private cdDatePipe: CdDatePipe,
114 private actionLabels: ActionLabelsI18n,
115 private notificationService: NotificationService,
116 private dimlessBinaryPipe: DimlessBinaryPipe
119 private selectAndShowNode(tree: TreeModel, node: TreeNode, $event: any) {
120 TREE_ACTIONS.TOGGLE_EXPANDED(tree, node, $event);
121 this.selectNode(node);
124 private selectNode(node: TreeNode) {
125 TREE_ACTIONS.TOGGLE_ACTIVE(undefined, node, undefined);
126 this.selectedDir = this.getDirectory(node);
127 if (node.id === '/') {
130 this.setSettings(node);
134 this.permission = this.authStorageService.getPermissions().cephfs;
135 this.setUpQuotaTable();
136 this.setUpSnapshotTable();
139 private setUpQuotaTable() {
144 name: this.i18n('Name'),
149 name: this.i18n('Value'),
154 prop: 'row.originPath',
155 name: this.i18n('Origin'),
157 cellTemplate: this.originTmpl,
161 selection: new CdTableSelection(),
162 updateSelection: (selection: CdTableSelection) => {
163 this.quota.selection = selection;
167 name: this.actionLabels.SET,
169 permission: 'update',
170 visible: (selection) =>
171 !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
172 click: () => this.updateQuotaModal()
175 name: this.actionLabels.UPDATE,
177 permission: 'update',
178 visible: (selection) => selection.first() && selection.first().dirValue > 0,
179 click: () => this.updateQuotaModal()
182 name: this.actionLabels.UNSET,
184 permission: 'update',
185 disable: (selection) =>
186 !selection.hasSelection || (selection.first() && selection.first().dirValue === 0),
187 click: () => this.unsetQuotaModal()
193 private setUpSnapshotTable() {
198 name: this.i18n('Name'),
203 name: this.i18n('Path'),
209 name: this.i18n('Created'),
211 pipe: this.cdDatePipe
214 selection: new CdTableSelection(),
215 updateSelection: (selection: CdTableSelection) => {
216 this.snapshot.selection = selection;
220 name: this.actionLabels.CREATE,
222 permission: 'create',
223 canBePrimary: (selection) => !selection.hasSelection,
224 click: () => this.createSnapshot()
227 name: this.actionLabels.DELETE,
229 permission: 'delete',
230 click: () => this.deleteSnapshotModal(),
231 canBePrimary: (selection) => selection.hasSelection,
232 disable: (selection) => !selection.hasSelection
239 this.selectedDir = undefined;
241 this.requestedPaths = [];
249 private setRootNode() {
259 private firstCall() {
262 this.getNode(path).loadNodeChildren();
266 updateDirectory(path: string): Promise<any[]> {
267 this.unsetLoadingIndicator();
268 if (!this.requestedPaths.includes(path)) {
269 this.requestedPaths.push(path);
270 } else if (this.loading[path] === true) {
271 return undefined; // Path is currently fetched.
273 return new Promise((resolve) => {
274 this.setLoadingIndicator(path, true);
275 this.cephfsService.lsDir(this.id, path).subscribe((dirs) => {
276 this.updateTreeStructure(dirs);
277 this.updateQuotaTable();
279 resolve(this.getChildren(path));
280 this.setLoadingIndicator(path, false);
285 private setLoadingIndicator(path: string, loading: boolean) {
286 this.loading[path] = loading;
287 this.unsetLoadingIndicator();
290 private getSubDirectories(path: string, tree: CephfsDir[] = this.dirs): CephfsDir[] {
291 return tree.filter((d) => d.parent === path);
294 private getChildren(path: string): any[] {
295 const subTree = this.getSubTree(path);
296 return _.sortBy(this.getSubDirectories(path), 'path').map((dir) =>
297 this.createNode(dir, subTree)
301 private createNode(dir: CephfsDir, subTree?: CephfsDir[]): any {
302 this.nodeIds[dir.path] = dir;
304 this.getSubTree(dir.parent);
309 hasChildren: this.getSubDirectories(dir.path, subTree).length > 0
313 private getSubTree(path: string): CephfsDir[] {
314 return this.dirs.filter((d) => d.parent && d.parent.startsWith(path));
317 private setSettings(node: TreeNode) {
318 const readable = (value: number, fn?: (arg0: number) => number | string): number | string =>
319 value ? (fn ? fn(value) : value) : '';
322 this.getQuota(node, 'max_files', readable),
323 this.getQuota(node, 'max_bytes', (value) =>
324 readable(value, (v) => this.dimlessBinaryPipe.transform(v))
332 valueConvertFn: (number: number) => number | string
334 // Get current maximum
335 const currentPath = tree.id;
336 tree = this.getOrigin(tree, quotaKey);
337 const dir = this.getDirectory(tree);
338 const value = dir.quotas[quotaKey];
339 // Get next tree maximum
340 // => The value that isn't changeable through a change of the current directories quota value
341 let nextMaxValue = value;
342 let nextMaxPath = dir.path;
343 if (tree.id === currentPath) {
344 if (tree.parent.id === '/') {
345 // The value will never inherit any other value, so it has no maximum.
348 const nextMaxDir = this.getDirectory(this.getOrigin(tree.parent, quotaKey));
349 nextMaxValue = nextMaxDir.quotas[quotaKey];
350 nextMaxPath = nextMaxDir.path;
355 name: quotaKey === 'max_bytes' ? this.i18n('Max size') : this.i18n('Max files'),
356 value: valueConvertFn(value),
357 originPath: value ? dir.path : ''
360 dirValue: this.nodeIds[currentPath].quotas[quotaKey],
363 path: nextMaxValue ? nextMaxPath : ''
369 * Get the node where the quota limit originates from in the current node
371 * Example as it's a recursive method:
373 * | Path + Value | Call depth | useOrigin? | Output |
374 * |:-------------:|:----------:|:---------------------:|:------:|
375 * | /a/b/c/d (15) | 1st | 2nd (5) < 15 => false | /a/b |
376 * | /a/b/c (20) | 2nd | 3rd (5) < 20 => false | /a/b |
377 * | /a/b (5) | 3rd | 4th (10) < 5 => true | /a/b |
378 * | /a (10) | 4th | 10 => true | /a |
381 private getOrigin(tree: TreeNode, quotaSetting: string): TreeNode {
382 if (tree.parent && tree.parent.id !== '/') {
383 const current = this.getQuotaFromTree(tree, quotaSetting);
385 // Get the next used quota and node above the current one (until it hits the root directory)
386 const originTree = this.getOrigin(tree.parent, quotaSetting);
387 const inherited = this.getQuotaFromTree(originTree, quotaSetting);
389 // Select if the current quota is in use or the above
390 const useOrigin = current === 0 || (inherited !== 0 && inherited < current);
391 return useOrigin ? originTree : tree;
396 private getQuotaFromTree(tree: TreeNode, quotaSetting: string): number {
397 return this.getDirectory(tree).quotas[quotaSetting];
400 private getDirectory(node: TreeNode): CephfsDir {
401 const path = node.id as string;
402 return this.nodeIds[path];
405 selectOrigin(path: string) {
406 this.selectNode(this.getNode(path));
409 private getNode(path: string): TreeNode {
410 return this.treeComponent.treeModel.getNodeById(path);
414 const path = this.selectedDir.path;
415 const selection: QuotaSetting = this.quota.selection.first();
416 const nextMax = selection.nextTreeMaximum;
417 const key = selection.quotaKey;
418 const value = selection.dirValue;
419 this.modalService.show(FormModalComponent, {
420 titleText: this.getModalQuotaTitle(
421 value === 0 ? this.actionLabels.SET : this.actionLabels.UPDATE,
424 message: nextMax.value
425 ? this.i18n('The inherited {{quotaValue}} is the maximum value to be used.', {
426 quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
429 fields: [this.getQuotaFormField(selection.row.name, key, value, nextMax.value)],
430 submitButtonText: this.i18n('Save'),
431 onSubmit: (values: CephfsQuotas) => this.updateQuota(values)
435 private getModalQuotaTitle(action: string, path: string): string {
436 return this.i18n(`{{action}} CephFS {{quotaName}} quota for '{{path}}'`, {
438 quotaName: this.getQuotaName(),
443 private getQuotaName(): string {
444 return this.isBytesQuotaSelected() ? this.i18n('size') : this.i18n('files');
447 private isBytesQuotaSelected(): boolean {
448 return this.quota.selection.first().quotaKey === 'max_bytes';
451 private getQuotaValueFromPathMsg(value: number, path: string): string {
452 return this.i18n(`{{quotaName}} quota {{value}} from '{{path}}'`, {
453 value: this.isBytesQuotaSelected() ? this.dimlessBinaryPipe.transform(value) : value,
454 quotaName: this.getQuotaName(),
459 private getQuotaFormField(
464 ): CdFormModalFieldConfig {
465 const isBinary = name === 'max_bytes';
466 const formValidators = [isBinary ? CdValidators.binaryMin(0) : Validators.min(0)];
468 formValidators.push(isBinary ? CdValidators.binaryMax(maxValue) : Validators.max(maxValue));
470 const field: CdFormModalFieldConfig = {
471 type: isBinary ? 'binary' : 'number',
475 validators: formValidators,
480 min: this.i18n(`Value has to be at least {{value}} or more`, { value: 0 }),
481 max: this.i18n(`Value has to be at most {{value}} or less`, { value: maxValue })
487 private updateQuota(values: CephfsQuotas, onSuccess?: Function) {
488 const path = this.selectedDir.path;
489 const key = this.quota.selection.first().quotaKey;
491 this.selectedDir.quotas[key] === 0
492 ? this.actionLabels.SET
494 ? this.actionLabels.UNSET
495 : this.i18n('Updated');
496 this.cephfsService.updateQuota(this.id, path, values).subscribe(() => {
500 this.notificationService.show(
501 NotificationType.success,
502 this.getModalQuotaTitle(action, path)
504 this.forceDirRefresh();
509 const path = this.selectedDir.path;
510 const selection: QuotaSetting = this.quota.selection.first();
511 const key = selection.quotaKey;
512 const nextMax = selection.nextTreeMaximum;
513 const dirValue = selection.dirValue;
515 this.modalRef = this.modalService.show(ConfirmationModalComponent, {
516 titleText: this.getModalQuotaTitle(this.actionLabels.UNSET, path),
517 buttonText: this.actionLabels.UNSET,
518 description: this.i18n(`{{action}} {{quotaValue}} {{conclusion}}.`, {
519 action: this.actionLabels.UNSET,
520 quotaValue: this.getQuotaValueFromPathMsg(dirValue, path),
523 ? nextMax.value > dirValue
524 ? this.i18n('in order to inherit {{quotaValue}}', {
525 quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
527 : this.i18n(`which isn't used because of the inheritance of {{quotaValue}}`, {
528 quotaValue: this.getQuotaValueFromPathMsg(nextMax.value, nextMax.path)
530 : this.i18n('in order to have no quota on the directory')
532 onSubmit: () => this.updateQuota({ [key]: 0 }, () => this.modalRef.close())
537 // Create a snapshot. Auto-generate a snapshot name by default.
538 const path = this.selectedDir.path;
539 this.modalService.show(FormModalComponent, {
540 titleText: this.i18n('Create Snapshot'),
541 message: this.i18n('Please enter the name of the snapshot.'),
546 value: `${moment().toISOString(true)}`,
550 submitButtonText: this.i18n('Create Snapshot'),
551 onSubmit: (values: CephfsSnapshot) => {
552 this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
553 this.notificationService.show(
554 NotificationType.success,
555 this.i18n(`Created snapshot '{{name}}' for '{{path}}'`, {
560 this.forceDirRefresh();
567 * Forces an update of the current selected directory
569 * As all nodes point by their path on an directory object, the easiest way is to update
570 * the objects by merge with their latest change.
572 private forceDirRefresh(path?: string) {
574 const dir = this.selectedDir;
576 throw new Error('This function can only be called without path if an selection was made');
578 // Parent has to be called in order to update the object referring
579 // to the current selected directory
580 path = dir.parent ? dir.parent : dir.path;
582 const node = this.getNode(path);
583 node.loadNodeChildren();
586 private updateTreeStructure(dirs: CephfsDir[]) {
587 const getChildrenAndPaths = (
588 directories: CephfsDir[],
590 ): { children: CephfsDir[]; paths: string[] } => {
591 const children = directories.filter((d) => d.parent === parent);
592 const paths = children.map((d) => d.path);
593 return { children, paths };
596 const parents = _.uniq(dirs.map((d) => d.parent).sort());
597 parents.forEach((p) => {
598 const received = getChildrenAndPaths(dirs, p);
599 const cached = getChildrenAndPaths(this.dirs, p);
601 cached.children.forEach((d) => {
602 if (!received.paths.includes(d.path)) {
603 this.removeOldDirectory(d);
606 received.children.forEach((d) => {
607 if (cached.paths.includes(d.path)) {
608 this.updateExistingDirectory(cached.children, d);
610 this.addNewDirectory(d);
616 private removeOldDirectory(rmDir: CephfsDir) {
617 const path = rmDir.path;
618 // Remove directory from local variables
619 _.remove(this.dirs, (d) => d.path === path);
620 delete this.nodeIds[path];
621 this.updateDirectoriesParentNode(rmDir);
624 private updateDirectoriesParentNode(dir: CephfsDir) {
625 const parent = dir.parent;
629 const node = this.getNode(parent);
631 // Node will not be found for new sub sub directories - this is the intended behaviour
634 const children = this.getChildren(parent);
635 node.data.children = children;
636 node.data.hasChildren = children.length > 0;
637 this.treeComponent.treeModel.update();
640 private addNewDirectory(newDir: CephfsDir) {
641 this.dirs.push(newDir);
642 this.nodeIds[newDir.path] = newDir;
643 this.updateDirectoriesParentNode(newDir);
646 private updateExistingDirectory(source: CephfsDir[], updatedDir: CephfsDir) {
647 const currentDirObject = source.find((sub) => sub.path === updatedDir.path);
648 Object.assign(currentDirObject, updatedDir);
651 private updateQuotaTable() {
652 const node = this.selectedDir ? this.getNode(this.selectedDir.path) : undefined;
653 if (node && node.id !== '/') {
654 this.setSettings(node);
658 private updateTree(force: boolean = false) {
659 if (this.loadingIndicator && !force) {
660 // In order to make the page scrollable during load, the render cycle for each node
661 // is omitted and only be called if all updates were loaded.
664 this.treeComponent.treeModel.update();
665 this.nodes = [...this.nodes];
666 this.treeComponent.sizeChanged();
669 deleteSnapshotModal() {
670 this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
671 itemDescription: this.i18n('CephFs Snapshot'),
672 itemNames: this.snapshot.selection.selected.map((snapshot: CephfsSnapshot) => snapshot.name),
673 submitAction: () => this.deleteSnapshot()
678 const path = this.selectedDir.path;
679 this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => {
680 const name = snapshot.name;
681 this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => {
682 this.notificationService.show(
683 NotificationType.success,
684 this.i18n(`Deleted snapshot '{{name}}' for '{{path}}'`, {
691 this.modalRef.close();
692 this.forceDirRefresh();
695 refreshAllDirectories() {
696 // In order to make the page scrollable during load, the render cycle for each node
697 // is omitted and only be called if all updates were loaded.
698 this.loadingIndicator = true;
699 this.requestedPaths.map((path) => this.forceDirRefresh(path));
700 const interval = setInterval(() => {
701 this.updateTree(true);
702 if (!this.loadingIndicator) {
703 clearInterval(interval);
708 unsetLoadingIndicator() {
709 if (!this.loadingIndicator) {
712 clearTimeout(this.loadingTimeout);
713 this.loadingTimeout = setTimeout(() => {
714 const loading = Object.values(this.loading).some((l) => l);
716 return this.unsetLoadingIndicator();
718 this.loadingIndicator = false;
720 // The problem is that we can't subscribe to an useful updated tree event and the time
721 // between fetching all calls and rebuilding the tree can take some time