]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: CephFS snapshot management UI 30996/head
authorStephan Müller <smueller@suse.com>
Tue, 15 Oct 2019 13:37:44 +0000 (15:37 +0200)
committerStephan Müller <smueller@suse.com>
Tue, 19 Nov 2019 11:27:39 +0000 (12:27 +0100)
CephFS snapshots can now be created on a directory basis. Multiple
snapshots can be deleted at once.

Fixes: https://tracker.ceph.com/issues/41791
Signed-off-by: Volker Theile <vtheile@suse.com>
Signed-off-by: Stephan Müller <smueller@suse.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-directories/cephfs-directories.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html [new file with mode: 0755]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss [new file with mode: 0755]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts [new file with mode: 0755]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts [new file with mode: 0755]
src/pybind/mgr/dashboard/frontend/src/testing/unit-test-helper.ts

index e5954761dc35c4a263ff6048b67bdd9a7895571e..80d7a6005a7193533c74578c414565df0cebe1da 100644 (file)
         </cd-table>
 
         <legend i18n>Snapshots</legend>
-        <cd-table [sorts]="snapshot.sortProperties"
-                  [data]="selectedDir.snapshots"
-                  [columns]="snapshot.columns">
+        <cd-table [data]="selectedDir.snapshots"
+                  [columns]="snapshot.columns"
+                  identifier="name"
+                  forceIdentifier="true"
+                  selectionType="multiClick"
+                  (updateSelection)="snapshot.updateSelection($event)">
+          <cd-table-actions class="table-actions"
+                            [permission]="permission"
+                            [selection]="snapshot.selection"
+                            [tableActions]="snapshot.tableActions">
+          </cd-table-actions>
         </cd-table>
       </div>
     </div>
index 90d283e320c4d6b0a886e57f4bdf025d3e46033c..7f3eba70e290956464b2af809d6457cf0e45649a 100644 (file)
@@ -1,10 +1,19 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
 
+import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation';
 import { NodeEvent, Tree, TreeModel, TreeModule } from 'ng2-tree';
+import { BsModalRef, BsModalService, ModalModule } from 'ngx-bootstrap/modal';
+import { ToastrModule } from 'ngx-toastr';
 import { of } from 'rxjs';
 
-import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper';
+import {
+  configureTestBed,
+  i18nProviders,
+  modalServiceShow,
+  PermissionHelper
+} from '../../../../testing/unit-test-helper';
 import { CephfsService } from '../../../shared/api/cephfs.service';
 import {
   CephfsDir,
@@ -17,8 +26,10 @@ import { CephfsDirectoriesComponent } from './cephfs-directories.component';
 describe('CephfsDirectoriesComponent', () => {
   let component: CephfsDirectoriesComponent;
   let fixture: ComponentFixture<CephfsDirectoriesComponent>;
+  let cephfsService: CephfsService;
   let lsDirSpy;
   let originalDate;
+  let modal;
 
   // Get's private attributes or functions
   const get = {
@@ -31,6 +42,8 @@ describe('CephfsDirectoriesComponent', () => {
   let mockData: {
     nodes: TreeModel[];
     parent: Tree;
+    createdSnaps: CephfsSnapshot[] | any[];
+    deletedSnaps: CephfsSnapshot[] | any[];
   };
 
   // Object contains mock functions
@@ -40,22 +53,34 @@ describe('CephfsDirectoriesComponent', () => {
       const name = 'someSnapshot';
       const snapshots = [];
       for (let i = 0; i < howMany; i++) {
-        const path = `${dirPath}/.snap/${name}${i}`;
+        const snapName = `${name}${i + 1}`;
+        const path = `${dirPath}/.snap/${snapName}`;
         const created = new Date(
           +new Date() - 3600 * 24 * 1000 * howMany * (howMany - i)
         ).toString();
-        snapshots.push({ name, path, created });
+        snapshots.push({ name: snapName, path, created });
       }
       return snapshots;
     },
     dir: (path: string, name: string, modifier: number): CephfsDir => {
       const dirPath = `${path === '/' ? '' : path}/${name}`;
+      let snapshots = mockLib.snapshots(path, modifier);
+      const extraSnapshots = mockData.createdSnaps.filter((s) => s.path === dirPath);
+      if (extraSnapshots.length > 0) {
+        snapshots = snapshots.concat(extraSnapshots);
+      }
+      const deletedSnapshots = mockData.deletedSnaps
+        .filter((s) => s.path === dirPath)
+        .map((s) => s.name);
+      if (deletedSnapshots.length > 0) {
+        snapshots = snapshots.filter((s) => !deletedSnapshots.includes(s.name));
+      }
       return {
         name,
         path: dirPath,
         parent: path,
         quotas: mockLib.quotas(1024 * modifier, 10 * modifier),
-        snapshots: mockLib.snapshots(path, modifier)
+        snapshots: snapshots
       };
     },
     // Only used inside other mocks
@@ -80,6 +105,26 @@ describe('CephfsDirectoriesComponent', () => {
       });
       return of(data);
     },
+    mkSnapshot: (_id, path, name) => {
+      mockData.createdSnaps.push({
+        name,
+        path,
+        created: new Date().toString()
+      });
+      return of(name);
+    },
+    rmSnapshot: (_id, path, name) => {
+      mockData.deletedSnaps.push({
+        name,
+        path,
+        created: new Date().toString()
+      });
+      return of(name);
+    },
+    modalShow: (comp, init) => {
+      modal = modalServiceShow(comp, init);
+      return modal.ref;
+    },
     date: (arg) => (arg ? new originalDate(arg) : new Date('2022-02-22T00:00:00')),
     getControllerByPath: (path: string) => {
       return {
@@ -89,7 +134,9 @@ describe('CephfsDirectoriesComponent', () => {
     },
     // Only used inside other mocks to mock "tree.expand" of every node
     expand: (path: string) => {
-      component.updateDirectory(path, (nodes) => (mockData.nodes = mockData.nodes.concat(nodes)));
+      component.updateDirectory(path, (nodes) => {
+        mockData.nodes = mockData.nodes.concat(nodes);
+      });
     },
     getNodeEvent: (path: string): NodeEvent => {
       const tree = mockData.nodes.find((n) => n.id === path) as Tree;
@@ -119,6 +166,15 @@ describe('CephfsDirectoriesComponent', () => {
         id: dir.path,
         value: name
       });
+    },
+    createSnapshotThroughModal: (name: string) => {
+      component.createSnapshot();
+      modal.component.onSubmitForm({ name });
+    },
+    deleteSnapshotsThroughModal: (snapshots: CephfsSnapshot[]) => {
+      component.snapshot.selection.selected = snapshots;
+      component.deleteSnapshotModal();
+      modal.component.callSubmitAction();
     }
   };
 
@@ -128,6 +184,8 @@ describe('CephfsDirectoriesComponent', () => {
     nodeLength: (n: number) => expect(mockData.nodes.length).toBe(n),
     lsDirCalledTimes: (n: number) => expect(lsDirSpy).toHaveBeenCalledTimes(n),
     requestedPaths: (expected: string[]) => expect(get.requestedPaths()).toEqual(expected),
+    snapshotsByName: (snaps: string[]) =>
+      expect(component.selectedDir.snapshots.map((s) => s.name)).toEqual(snaps),
     quotaSettings: (
       fileValue: number | string,
       fileOrigin: string,
@@ -141,20 +199,35 @@ describe('CephfsDirectoriesComponent', () => {
   };
 
   configureTestBed({
-    imports: [HttpClientTestingModule, SharedModule, TreeModule],
+    imports: [
+      HttpClientTestingModule,
+      SharedModule,
+      RouterTestingModule,
+      TreeModule,
+      NgBootstrapFormValidationModule.forRoot(),
+      ToastrModule.forRoot(),
+      ModalModule.forRoot()
+    ],
     declarations: [CephfsDirectoriesComponent],
-    providers: [i18nProviders]
+    providers: [i18nProviders, BsModalRef]
   });
 
   beforeEach(() => {
     mockData = {
       nodes: undefined,
-      parent: undefined
+      parent: undefined,
+      createdSnaps: [],
+      deletedSnaps: []
     };
     originalDate = Date;
     spyOn(global, 'Date').and.callFake(mockLib.date);
 
-    lsDirSpy = spyOn(TestBed.get(CephfsService), 'lsDir').and.callFake(mockLib.lsDir);
+    cephfsService = TestBed.get(CephfsService);
+    lsDirSpy = spyOn(cephfsService, 'lsDir').and.callFake(mockLib.lsDir);
+    spyOn(cephfsService, 'mkSnapshot').and.callFake(mockLib.mkSnapshot);
+    spyOn(cephfsService, 'rmSnapshot').and.callFake(mockLib.rmSnapshot);
+
+    spyOn(TestBed.get(BsModalService), 'show').and.callFake(mockLib.modalShow);
 
     fixture = TestBed.createComponent(CephfsDirectoriesComponent);
     component = fixture.componentInstance;
@@ -169,6 +242,58 @@ describe('CephfsDirectoriesComponent', () => {
     expect(component).toBeTruthy();
   });
 
+  describe('mock self test', () => {
+    it('tests snapshots mock', () => {
+      expect(mockLib.snapshots('/a', 1).map((s) => ({ name: s.name, path: s.path }))).toEqual([
+        {
+          name: 'someSnapshot1',
+          path: '/a/.snap/someSnapshot1'
+        }
+      ]);
+      expect(mockLib.snapshots('/a/b', 3).map((s) => ({ name: s.name, path: s.path }))).toEqual([
+        {
+          name: 'someSnapshot1',
+          path: '/a/b/.snap/someSnapshot1'
+        },
+        {
+          name: 'someSnapshot2',
+          path: '/a/b/.snap/someSnapshot2'
+        },
+        {
+          name: 'someSnapshot3',
+          path: '/a/b/.snap/someSnapshot3'
+        }
+      ]);
+    });
+
+    it('tests dir mock', () => {
+      const path = '/a/b/c';
+      mockData.createdSnaps = [{ path, name: 's1' }, { path, name: 's2' }];
+      mockData.deletedSnaps = [{ path, name: 'someSnapshot2' }, { path, name: 's2' }];
+      const dir = mockLib.dir('/a/b', 'c', 2);
+      expect(dir.path).toBe('/a/b/c');
+      expect(dir.parent).toBe('/a/b');
+      expect(dir.quotas).toEqual({ max_bytes: 2048, max_files: 20 });
+      expect(dir.snapshots.map((s) => s.name)).toEqual(['someSnapshot1', 's1']);
+    });
+
+    it('tests lsdir mock', () => {
+      let dirs: CephfsDir[] = [];
+      mockLib.lsDir(2, '/a').subscribe((x) => (dirs = x));
+      expect(dirs.map((d) => d.path)).toEqual([
+        '/a/c',
+        '/a/a',
+        '/a/b',
+        '/a/c/c',
+        '/a/c/a',
+        '/a/c/b',
+        '/a/a/c',
+        '/a/a/a',
+        '/a/a/b'
+      ]);
+    });
+  });
+
   it('calls lsDir only if an id exits', () => {
     component.ngOnChanges();
     assert.lsDirCalledTimes(0);
@@ -320,4 +445,76 @@ describe('CephfsDirectoriesComponent', () => {
       });
     });
   });
+
+  describe('snapshots', () => {
+    beforeEach(() => {
+      mockLib.changeId(1);
+      mockLib.selectNode('/a');
+    });
+
+    it('should create a snapshot', () => {
+      mockLib.createSnapshotThroughModal('newSnap');
+      expect(cephfsService.mkSnapshot).toHaveBeenCalledWith(1, '/a', 'newSnap');
+      assert.snapshotsByName(['someSnapshot1', 'newSnap']);
+    });
+
+    it('should delete a snapshot', () => {
+      mockLib.createSnapshotThroughModal('deleteMe');
+      mockLib.deleteSnapshotsThroughModal([component.selectedDir.snapshots[1]]);
+      assert.snapshotsByName(['someSnapshot1']);
+    });
+
+    it('should delete all snapshots', () => {
+      mockLib.createSnapshotThroughModal('deleteAll');
+      mockLib.deleteSnapshotsThroughModal(component.selectedDir.snapshots);
+      assert.snapshotsByName([]);
+    });
+
+    afterEach(() => {
+      // Makes sure the directory is updated correctly
+      expect(component.selectedDir).toEqual(get.nodeIds()[component.selectedDir.path]);
+    });
+  });
+
+  it('should test all snapshot table actions combinations', () => {
+    const permissionHelper: PermissionHelper = new PermissionHelper(component.permission);
+    const tableActions = permissionHelper.setPermissionsAndGetActions(
+      component.snapshot.tableActions
+    );
+
+    expect(tableActions).toEqual({
+      'create,update,delete': {
+        actions: ['Create', 'Delete'],
+        primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Create' }
+      },
+      'create,update': {
+        actions: ['Create'],
+        primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+      },
+      'create,delete': {
+        actions: ['Create', 'Delete'],
+        primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Create' }
+      },
+      create: {
+        actions: ['Create'],
+        primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' }
+      },
+      'update,delete': {
+        actions: ['Delete'],
+        primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+      },
+      update: {
+        actions: [],
+        primary: { multiple: '', executing: '', single: '', no: '' }
+      },
+      delete: {
+        actions: ['Delete'],
+        primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' }
+      },
+      'no-permissions': {
+        actions: [],
+        primary: { multiple: '', executing: '', single: '', no: '' }
+      }
+    });
+  });
 });
index b70286f5657409a1b12ee40488647f7b3a83514c..58f370de0b90fe4b928e480f401974e3b9a59ed8 100644 (file)
@@ -1,15 +1,25 @@
 import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
 
 import { I18n } from '@ngx-translate/i18n-polyfill';
-import { SortDirection, SortPropDir } from '@swimlane/ngx-datatable';
 import * as _ from 'lodash';
+import * as moment from 'moment';
 import { NodeEvent, Tree, TreeComponent, TreeModel } from 'ng2-tree';
 
+import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
 import { CephfsService } from '../../../shared/api/cephfs.service';
+import { CriticalConfirmationModalComponent } from '../../../shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { FormModalComponent } from '../../../shared/components/form-modal/form-modal.component';
+import { Icons } from '../../../shared/enum/icons.enum';
+import { NotificationType } from '../../../shared/enum/notification-type.enum';
+import { CdTableAction } from '../../../shared/models/cd-table-action';
 import { CdTableColumn } from '../../../shared/models/cd-table-column';
-import { CephfsDir } from '../../../shared/models/cephfs-directory-models';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { CephfsDir, CephfsSnapshot } from '../../../shared/models/cephfs-directory-models';
+import { Permission } from '../../../shared/models/permissions';
 import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
 import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { NotificationService } from '../../../shared/services/notification.service';
 
 @Component({
   selector: 'cd-cephfs-directories',
@@ -25,29 +35,40 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
   @Input()
   id: number;
 
+  private modalRef: BsModalRef;
   private dirs: CephfsDir[];
   private nodeIds: { [path: string]: CephfsDir };
   private requestedPaths: string[];
+  private selectedNode: Tree;
 
+  permission: Permission;
   selectedDir: CephfsDir;
-  tree: TreeModel;
   settings: {
     name: string;
     value: number | string;
     origin: string;
   }[];
-
   settingsColumns: CdTableColumn[];
-  snapshot: { columns: CdTableColumn[]; sortProperties: SortPropDir[] };
+  snapshot: {
+    columns: CdTableColumn[];
+    selection: CdTableSelection;
+    tableActions: CdTableAction[];
+    updateSelection: Function;
+  };
+  tree: TreeModel;
 
   constructor(
+    private authStorageService: AuthStorageService,
+    private modalService: BsModalService,
     private cephfsService: CephfsService,
     private cdDatePipe: CdDatePipe,
     private i18n: I18n,
+    private notificationService: NotificationService,
     private dimlessBinaryPipe: DimlessBinaryPipe
   ) {}
 
   ngOnInit() {
+    this.permission = this.authStorageService.getPermissions().cephfs;
     this.settingsColumns = [
       {
         prop: 'name',
@@ -88,10 +109,25 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
           pipe: this.cdDatePipe
         }
       ],
-      sortProperties: [
+      selection: new CdTableSelection(),
+      updateSelection: (selection: CdTableSelection) => {
+        this.snapshot.selection = selection;
+      },
+      tableActions: [
+        {
+          name: this.i18n('Create'),
+          icon: Icons.add,
+          permission: 'create',
+          canBePrimary: (selection) => !selection.hasSelection,
+          click: () => this.createSnapshot()
+        },
         {
-          dir: SortDirection.desc,
-          prop: 'created'
+          name: this.i18n('Delete'),
+          icon: Icons.destroy,
+          permission: 'delete',
+          click: () => this.deleteSnapshotModal(),
+          canBePrimary: (selection) => selection.hasSelection,
+          disable: (selection) => !selection.hasSelection
         }
       ]
     };
@@ -148,11 +184,11 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
 
   private loadDirectory(data: CephfsDir[], path: string, callback: (x: any[]) => void) {
     if (path !== '/') {
-      // Removes duplicate directories
+      // As always to levels are loaded all sub-directories of the current called path are
+      // already loaded, that's why they are filtered out.
       data = data.filter((dir) => dir.parent !== path);
     }
-    const dirs = this.dirs.concat(data);
-    this.dirs = dirs;
+    this.dirs = this.dirs.concat(data);
     this.getChildren(path, callback);
   }
 
@@ -187,6 +223,7 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
     this.treeComponent.getControllerByNodeId(node.id).expand();
     this.setSettings(node);
     this.selectedDir = this.getDirectory(node);
+    this.selectedNode = node;
   }
 
   private setSettings(node: Tree) {
@@ -236,4 +273,81 @@ export class CephfsDirectoriesComponent implements OnInit, OnChanges {
     const path = node.id as string;
     return this.nodeIds[path];
   }
+
+  createSnapshot() {
+    // Create a snapshot. Auto-generate a snapshot name by default.
+    const path = this.selectedDir.path;
+    this.modalService.show(FormModalComponent, {
+      initialState: {
+        titleText: this.i18n('Create Snapshot'),
+        message: this.i18n('Please enter the name of the snapshot.'),
+        fields: [
+          {
+            type: 'inputText',
+            name: 'name',
+            value: `${moment().toISOString(true)}`,
+            required: true
+          }
+        ],
+        submitButtonText: this.i18n('Create Snapshot'),
+        onSubmit: (values) => {
+          this.cephfsService.mkSnapshot(this.id, path, values.name).subscribe((name) => {
+            this.notificationService.show(
+              NotificationType.success,
+              this.i18n('Created snapshot "{{name}}" for "{{path}}"', {
+                name: name,
+                path: path
+              })
+            );
+            this.forceDirRefresh();
+          });
+        }
+      }
+    });
+  }
+
+  /**
+   * Forces an update of the current selected directory
+   *
+   * As all nodes point by their path on an directory object, the easiest way is to update
+   * the objects by merge with their latest change.
+   */
+  private forceDirRefresh() {
+    const path = this.selectedNode.parent.id as string;
+    this.cephfsService.lsDir(this.id, path).subscribe((data) =>
+      data.forEach((d) => {
+        Object.assign(this.dirs.find((sub) => sub.path === d.path), d);
+      })
+    );
+  }
+
+  deleteSnapshotModal() {
+    this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+      initialState: {
+        itemDescription: 'CephFs Snapshot',
+        itemNames: this.snapshot.selection.selected.map(
+          (snapshot: CephfsSnapshot) => snapshot.name
+        ),
+        submitAction: () => this.deleteSnapshot()
+      }
+    });
+  }
+
+  deleteSnapshot() {
+    const path = this.selectedDir.path;
+    this.snapshot.selection.selected.forEach((snapshot: CephfsSnapshot) => {
+      const name = snapshot.name;
+      this.cephfsService.rmSnapshot(this.id, path, name).subscribe(() => {
+        this.notificationService.show(
+          NotificationType.success,
+          this.i18n('Deleted snapshot "{{name}}" for "{{path}}"', {
+            name: name,
+            path: path
+          })
+        );
+      });
+    });
+    this.modalRef.hide();
+    this.forceDirRefresh();
+  }
 }
index 5910f5986422af0be9d2316c1584135d9937844a..f348584ca29265055b670f62369f478119d822f3 100644 (file)
@@ -61,6 +61,21 @@ describe('CephfsService', () => {
     const req = httpTesting.expectOne('api/cephfs/1/ls_dir?depth=2');
     expect(req.request.method).toBe('GET');
     service.lsDir(2, '/some/path').subscribe();
-    httpTesting.expectOne('api/cephfs/2/ls_dir?depth=2&path=%2Fsome%2Fpath');
+    httpTesting.expectOne('api/cephfs/2/ls_dir?depth=2&path=%252Fsome%252Fpath');
+  });
+
+  it('should call mkSnapshot', () => {
+    service.mkSnapshot(3, '/some/path').subscribe();
+    const req = httpTesting.expectOne('api/cephfs/3/mk_snapshot?path=%252Fsome%252Fpath');
+    expect(req.request.method).toBe('POST');
+
+    service.mkSnapshot(4, '/some/other/path', 'snap').subscribe();
+    httpTesting.expectOne('api/cephfs/4/mk_snapshot?path=%252Fsome%252Fother%252Fpath&name=snap');
+  });
+
+  it('should call rmSnapshot', () => {
+    service.rmSnapshot(1, '/some/path', 'snap').subscribe();
+    const req = httpTesting.expectOne('api/cephfs/1/rm_snapshot?path=%252Fsome%252Fpath&name=snap');
+    expect(req.request.method).toBe('POST');
   });
 });
index 997ba3ce73f0e892bc01b54e02b4d6b9d5855161..1c1da64ae77bae242c9ec9efafb2ab9083b26836 100644 (file)
@@ -1,11 +1,14 @@
-import { HttpClient } from '@angular/common/http';
+import { HttpClient, HttpParams } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
+import * as _ from 'lodash';
 import { Observable } from 'rxjs';
 
+import { cdEncode } from '../decorators/cd-encode';
 import { CephfsDir } from '../models/cephfs-directory-models';
 import { ApiModule } from './api.module';
 
+@cdEncode
 @Injectable({
   providedIn: ApiModule
 })
@@ -45,4 +48,20 @@ export class CephfsService {
   getMdsCounters(id) {
     return this.http.get(`${this.baseURL}/${id}/mds_counters`);
   }
+
+  mkSnapshot(id, path, name?) {
+    let params = new HttpParams();
+    params = params.append('path', path);
+    if (!_.isUndefined(name)) {
+      params = params.append('name', name);
+    }
+    return this.http.post(`${this.baseURL}/${id}/mk_snapshot`, null, { params: params });
+  }
+
+  rmSnapshot(id, path, name) {
+    let params = new HttpParams();
+    params = params.append('path', path);
+    params = params.append('name', name);
+    return this.http.post(`${this.baseURL}/${id}/rm_snapshot`, null, { params: params });
+  }
 }
index 7c66297c6276da2c87635aa582c1c9bcfd1ddf52..2edff8c53ab191877c4d861b77a7a6cd2464fa9e 100644 (file)
@@ -18,6 +18,7 @@ import { BackButtonComponent } from './back-button/back-button.component';
 import { ConfigOptionComponent } from './config-option/config-option.component';
 import { ConfirmationModalComponent } from './confirmation-modal/confirmation-modal.component';
 import { CriticalConfirmationModalComponent } from './critical-confirmation-modal/critical-confirmation-modal.component';
+import { FormModalComponent } from './form-modal/form-modal.component';
 import { GrafanaComponent } from './grafana/grafana.component';
 import { HelperComponent } from './helper/helper.component';
 import { LanguageSelectorComponent } from './language-selector/language-selector.component';
@@ -67,7 +68,8 @@ import { ViewCacheComponent } from './view-cache/view-cache.component';
     BackButtonComponent,
     RefreshSelectorComponent,
     ConfigOptionComponent,
-    AlertPanelComponent
+    AlertPanelComponent,
+    FormModalComponent
   ],
   providers: [],
   exports: [
@@ -88,6 +90,11 @@ import { ViewCacheComponent } from './view-cache/view-cache.component';
     ConfigOptionComponent,
     AlertPanelComponent
   ],
-  entryComponents: [ModalComponent, CriticalConfirmationModalComponent, ConfirmationModalComponent]
+  entryComponents: [
+    ModalComponent,
+    CriticalConfirmationModalComponent,
+    ConfirmationModalComponent,
+    FormModalComponent
+  ]
 })
 export class ComponentsModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html
new file mode 100755 (executable)
index 0000000..d193194
--- /dev/null
@@ -0,0 +1,46 @@
+<cd-modal [modalRef]="bsModalRef">
+  <ng-container *ngIf="titleText"
+                class="modal-title">
+    {{ titleText }}
+  </ng-container>
+  <ng-container class="modal-content">
+    <form [formGroup]="formGroup"
+          novalidate>
+      <div class="modal-body">
+        <p *ngIf="message">{{ message }}</p>
+        <ng-container *ngFor="let field of fields">
+          <div class="form-group row">
+            <ng-container [ngSwitch]="field.type">
+              <ng-template [ngSwitchCase]="'inputText'">
+                <label *ngIf="field.label"
+                       class="col-form-label col-sm-3"
+                       [for]="field.name">
+                  {{ field.label }}
+                </label>
+                <div [ngClass]="{'col-sm-9': field.label, 'col-sm-12': !field.label}">
+                  <input type="text"
+                         class="form-control"
+                         [id]="field.name"
+                         [name]="field.name"
+                         [formControlName]="field.name">
+                  <span *ngIf="formGroup.hasError('required', field.name)"
+                        class="invalid-feedback"
+                        i18n>This field is required.</span>
+                </div>
+              </ng-template>
+            </ng-container>
+          </div>
+        </ng-container>
+      </div>
+      <div class="modal-footer">
+        <div class="button-group text-right">
+          <cd-submit-button [form]="formGroup"
+                            (submitAction)="onSubmitForm(formGroup.value)">
+            {{ submitButtonText }}
+          </cd-submit-button>
+          <cd-back-button [back]="bsModalRef.hide"></cd-back-button>
+        </div>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss
new file mode 100755 (executable)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts
new file mode 100755 (executable)
index 0000000..70c1872
--- /dev/null
@@ -0,0 +1,93 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation';
+import { BsModalRef, ModalModule } from 'ngx-bootstrap/modal';
+
+import {
+  configureTestBed,
+  FixtureHelper,
+  i18nProviders
+} from '../../../../testing/unit-test-helper';
+import { SharedModule } from '../../shared.module';
+import { FormModalComponent } from './form-modal.component';
+
+describe('InputModalComponent', () => {
+  let component: FormModalComponent;
+  let fixture: ComponentFixture<FormModalComponent>;
+  let fh: FixtureHelper;
+  let submitted;
+
+  const initialState = {
+    titleText: 'Some title',
+    message: 'Some description',
+    fields: [
+      {
+        type: 'inputText',
+        name: 'requiredField',
+        value: 'some-value',
+        required: true
+      },
+      {
+        type: 'inputText',
+        name: 'optionalField',
+        label: 'Optional'
+      }
+    ],
+    submitButtonText: 'Submit button name',
+    onSubmit: (values) => (submitted = values)
+  };
+
+  configureTestBed({
+    imports: [
+      ModalModule.forRoot(),
+      NgBootstrapFormValidationModule.forRoot(),
+      RouterTestingModule,
+      ReactiveFormsModule,
+      SharedModule
+    ],
+    providers: [i18nProviders, BsModalRef]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(FormModalComponent);
+    component = fixture.componentInstance;
+    Object.assign(component, initialState);
+    fixture.detectChanges();
+    fh = new FixtureHelper(fixture);
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('has the defined title', () => {
+    fh.expectTextToBe('.modal-title', 'Some title');
+  });
+
+  it('has the defined description', () => {
+    fh.expectTextToBe('.modal-body > p', 'Some description');
+  });
+
+  it('should display both inputs', () => {
+    fh.expectElementVisible('#requiredField', true);
+    fh.expectElementVisible('#optionalField', true);
+  });
+
+  it('has one defined label field', () => {
+    fh.expectTextToBe('.col-form-label', 'Optional');
+  });
+
+  it('has a predefined values for requiredField', () => {
+    fh.expectFormFieldToBe('#requiredField', 'some-value');
+  });
+
+  it('gives back all form values on submit', () => {
+    component.onSubmitForm(component.formGroup.value);
+    expect(submitted).toEqual({
+      requiredField: 'some-value',
+      optionalField: null
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts
new file mode 100755 (executable)
index 0000000..a4da08b
--- /dev/null
@@ -0,0 +1,57 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, FormGroup, Validators } from '@angular/forms';
+
+import * as _ from 'lodash';
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { CdFormBuilder } from '../../forms/cd-form-builder';
+
+interface CdFormFieldConfig {
+  type: 'textInput';
+  name: string;
+  label?: string;
+  value?: any;
+  required?: boolean;
+}
+
+@Component({
+  selector: 'cd-form-modal',
+  templateUrl: './form-modal.component.html',
+  styleUrls: ['./form-modal.component.scss']
+})
+export class FormModalComponent implements OnInit {
+  // Input
+  titleText: string;
+  message: string;
+  fields: CdFormFieldConfig[];
+  submitButtonText: string;
+  onSubmit: Function;
+
+  // Internal
+  formGroup: FormGroup;
+
+  constructor(public bsModalRef: BsModalRef, private formBuilder: CdFormBuilder) {}
+
+  createForm() {
+    const controlsConfig = {};
+    this.fields.forEach((field) => {
+      const validators = [];
+      if (_.isBoolean(field.required) && field.required) {
+        validators.push(Validators.required);
+      }
+      controlsConfig[field.name] = new FormControl(_.defaultTo(field.value, null), { validators });
+    });
+    this.formGroup = this.formBuilder.group(controlsConfig);
+  }
+
+  ngOnInit() {
+    this.createForm();
+  }
+
+  onSubmitForm(values) {
+    this.bsModalRef.hide();
+    if (_.isFunction(this.onSubmit)) {
+      this.onSubmit(values);
+    }
+  }
+}
index 20605b028f9de1f6211dea09b16d0d94b189bd70..0b987f757199f846628bb8c5355e12e66342b12f 100644 (file)
@@ -237,6 +237,10 @@ export class FixtureHelper {
     expect(props['value'] || props['checked'].toString()).toBe(value);
   }
 
+  expectTextToBe(css: string, value: string) {
+    expect(this.getText(css)).toBe(value);
+  }
+
   clickElement(css: string) {
     this.getElementByCss(css).triggerEventHandler('click', null);
     this.fixture.detectChanges();