]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: cephfs subvolume list snapshots
authorNizamudeen A <nia@redhat.com>
Wed, 18 Oct 2023 17:46:09 +0000 (23:16 +0530)
committerNizamudeen A <nia@redhat.com>
Tue, 16 Jan 2024 06:40:20 +0000 (12:10 +0530)
Added a tab for displaying the subvolume snapshots
- this tab will show an info alert when there are no subvolumes present
- if the subvolume is present, then it'll be auto-selected by default

Implemented a filter to search the groups and subvolumes by its name.
Also added a scrollbar when there are too many items in the nav list

Modified the REST APIs to fetch only the names of the resources and
fetch the info when an API call is requesting for it.

Added unit tests

Fixes: https://tracker.ceph.com/issues/63237
Signed-off-by: Nizamudeen A <nia@redhat.com>
(cherry picked from commit b35be54ed9f23b7fc7859f054902e37cb88cefd8)

22 files changed:
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-group/cephfs-subvolume-group.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-list/cephfs-subvolume-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-list/cephfs-subvolume-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume-group.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-subvolume-group.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-subvolume.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-subvolumegroup.model.ts [deleted file]
src/pybind/mgr/dashboard/openapi.yaml

index 09b2bebfc1dfea6044042d82fe7e08d5c5e1447b..ac8333b6950353f8fe1d5627a985acf16a8eb734 100644 (file)
@@ -623,7 +623,7 @@ class CephFsUi(CephFS):
 @APIDoc('CephFS Subvolume Management API', 'CephFSSubvolume')
 class CephFSSubvolume(RESTController):
 
-    def get(self, vol_name: str, group_name: str = ""):
+    def get(self, vol_name: str, group_name: str = "", info=True):
         params = {'vol_name': vol_name}
         if group_name:
             params['group_name'] = group_name
@@ -634,15 +634,17 @@ class CephFSSubvolume(RESTController):
                 f'Failed to list subvolumes for volume {vol_name}: {err}'
             )
         subvolumes = json.loads(out)
-        for subvolume in subvolumes:
-            params['sub_name'] = subvolume['name']
-            error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_info', None,
-                                              params)
-            if error_code != 0:
-                raise DashboardException(
-                    f'Failed to get info for subvolume {subvolume["name"]}: {err}'
-                )
-            subvolume['info'] = json.loads(out)
+
+        if info:
+            for subvolume in subvolumes:
+                params['sub_name'] = subvolume['name']
+                error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_info', None,
+                                                  params)
+                if error_code != 0:
+                    raise DashboardException(
+                        f'Failed to get info for subvolume {subvolume["name"]}: {err}'
+                    )
+                subvolume['info'] = json.loads(out)
         return subvolumes
 
     @RESTController.Resource('GET')
@@ -699,12 +701,27 @@ class CephFSSubvolume(RESTController):
                 component='cephfs')
         return f'Subvolume {subvol_name} removed successfully'
 
+    @RESTController.Resource('GET')
+    def exists(self, vol_name: str, group_name=''):
+        params = {'vol_name': vol_name}
+        if group_name:
+            params['group_name'] = group_name
+        error_code, out, err = mgr.remote(
+            'volumes', '_cmd_fs_subvolume_exist', None, params)
+        if error_code != 0:
+            raise DashboardException(
+                f'Failed to check if subvolume exists: {err}'
+            )
+        if out == 'no subvolume exists':
+            return False
+        return True
+
 
 @APIRouter('/cephfs/subvolume/group', Scope.CEPHFS)
 @APIDoc("Cephfs Subvolume Group Management API", "CephfsSubvolumeGroup")
 class CephFSSubvolumeGroups(RESTController):
 
-    def get(self, vol_name):
+    def get(self, vol_name, info=True):
         if not vol_name:
             raise DashboardException(
                 f'Error listing subvolume groups for {vol_name}')
@@ -714,15 +731,17 @@ class CephFSSubvolumeGroups(RESTController):
             raise DashboardException(
                 f'Error listing subvolume groups for {vol_name}')
         subvolume_groups = json.loads(out)
-        for group in subvolume_groups:
-            error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolumegroup_info',
-                                              None, {'vol_name': vol_name,
-                                                     'group_name': group['name']})
-            if error_code != 0:
-                raise DashboardException(
-                    f'Failed to get info for subvolume group {group["name"]}: {err}'
-                )
-            group['info'] = json.loads(out)
+
+        if info:
+            for group in subvolume_groups:
+                error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolumegroup_info',
+                                                  None, {'vol_name': vol_name,
+                                                         'group_name': group['name']})
+                if error_code != 0:
+                    raise DashboardException(
+                        f'Failed to get info for subvolume group {group["name"]}: {err}'
+                    )
+                group['info'] = json.loads(out)
         return subvolume_groups
 
     @RESTController.Resource('GET')
@@ -763,3 +782,31 @@ class CephFSSubvolumeGroups(RESTController):
                 f'Failed to delete subvolume group {group_name}: {err}'
             )
         return f'Subvolume group {group_name} removed successfully'
+
+
+@APIRouter('/cephfs/subvolume/snapshot', Scope.CEPHFS)
+@APIDoc("Cephfs Subvolume Snapshot Management API", "CephfsSubvolumeSnapshot")
+class CephFSSubvolumeSnapshots(RESTController):
+    def get(self, vol_name: str, subvol_name, group_name: str = '', info=True):
+        params = {'vol_name': vol_name, 'sub_name': subvol_name}
+        if group_name:
+            params['group_name'] = group_name
+        error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_snapshot_ls', None,
+                                          params)
+        if error_code != 0:
+            raise DashboardException(
+                f'Failed to list subvolume snapshots for subvolume {subvol_name}: {err}'
+            )
+        snapshots = json.loads(out)
+
+        if info:
+            for snapshot in snapshots:
+                params['snap_name'] = snapshot['name']
+                error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_snapshot_info',
+                                                  None, params)
+                if error_code != 0:
+                    raise DashboardException(
+                        f'Failed to get info for subvolume snapshot {snapshot["name"]}: {err}'
+                    )
+                snapshot['info'] = json.loads(out)
+        return snapshots
index 3807ae61b67c935a535bab1d854426b192e022af..0e8768c85772db3b6758e2a787bc591f58422521 100644 (file)
@@ -9,7 +9,6 @@ import { CdTableAction } from '~/app/shared/models/cd-table-action';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
 import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
-import { CephfsSubvolumeGroup } from '~/app/shared/models/cephfs-subvolumegroup.model';
 import { CephfsSubvolumegroupFormComponent } from '../cephfs-subvolumegroup-form/cephfs-subvolumegroup-form.component';
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
@@ -18,6 +17,7 @@ import { Permissions } from '~/app/shared/models/permissions';
 import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CephfsSubvolumeGroup } from '~/app/shared/models/cephfs-subvolume-group.model';
 
 @Component({
   selector: 'cd-cephfs-subvolume-group',
index 29731bbbd1b06a9797b9aa9570e6fb4c653d0d24..f840c8dab116b7b89ee0ae2878040c3cb4086906 100644 (file)
@@ -1,21 +1,10 @@
 <div class="row">
-  <div class="col-sm-1">
-    <h3 i18n>Groups</h3>
-    <ng-container *ngIf="subVolumeGroups$ | async as subVolumeGroups">
-      <ul class="nav flex-column nav-pills">
-        <li class="nav-item">
-          <a class="nav-link"
-             [class.active]="!activeGroupName"
-             (click)="selectSubVolumeGroup()">Default</a>
-        </li>
-        <li class="nav-item"
-            *ngFor="let subVolumeGroup of subVolumeGroups">
-          <a class="nav-link text-decoration-none text-break"
-             [class.active]="subVolumeGroup.name === activeGroupName"
-             (click)="selectSubVolumeGroup(subVolumeGroup.name)">{{subVolumeGroup.name}}</a>
-        </li>
-      </ul>
-    </ng-container>
+  <div class="col-sm-1"
+       *ngIf="subVolumeGroups$ | async as subVolumeGroups">
+    <cd-vertical-navigation title="Groups"
+                            [items]="subvolumeGroupList"
+                            inputIdentifier="group-filter"
+                            (emitActiveItem)="selectSubVolumeGroup($event)"></cd-vertical-navigation>
   </div>
   <div class="col-11 vertical-line">
     <cd-table [data]="subVolumes$ | async"
index 3f679d27b96316a6ad6c106d63a049ee288c7ec8..2608dfbb3db62b1505193ac23b8d778864255913 100644 (file)
@@ -1,6 +1,6 @@
 import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
 import { Observable, ReplaySubject, of } from 'rxjs';
-import { catchError, shareReplay, switchMap } from 'rxjs/operators';
+import { catchError, shareReplay, switchMap, tap } from 'rxjs/operators';
 import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
@@ -22,7 +22,7 @@ import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { CdForm } from '~/app/shared/forms/cd-form';
 import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
 import { CephfsSubvolumeGroupService } from '~/app/shared/api/cephfs-subvolume-group.service';
-import { CephfsSubvolumeGroup } from '~/app/shared/models/cephfs-subvolumegroup.model';
+import { CephfsSubvolumeGroup } from '~/app/shared/models/cephfs-subvolume-group.model';
 
 @Component({
   selector: 'cd-cephfs-subvolume-list',
@@ -67,10 +67,12 @@ export class CephfsSubvolumeListComponent extends CdForm implements OnInit, OnCh
   subject = new ReplaySubject<CephfsSubvolume[]>();
   groupsSubject = new ReplaySubject<CephfsSubvolume[]>();
 
+  subvolumeGroupList: string[] = [];
+
   activeGroupName: string = '';
 
   constructor(
-    private cephfsSubVolume: CephfsSubvolumeService,
+    private cephfsSubVolumeService: CephfsSubvolumeService,
     private actionLabels: ActionLabelsI18n,
     private modalService: ModalService,
     private authStorageService: AuthStorageService,
@@ -150,7 +152,11 @@ export class CephfsSubvolumeListComponent extends CdForm implements OnInit, OnCh
 
     this.subVolumeGroups$ = this.groupsSubject.pipe(
       switchMap(() =>
-        this.cephfsSubvolumeGroupService.get(this.fsName).pipe(
+        this.cephfsSubvolumeGroupService.get(this.fsName, false).pipe(
+          tap((groups) => {
+            this.subvolumeGroupList = groups.map((group) => group.name);
+            this.subvolumeGroupList.unshift('');
+          }),
           catchError(() => {
             this.context.error();
             return of(null);
@@ -203,7 +209,7 @@ export class CephfsSubvolumeListComponent extends CdForm implements OnInit, OnCh
         this.taskWrapper
           .wrapTaskAroundCall({
             task: new FinishedTask('cephfs/subvolume/remove', { subVolumeName: this.selectedName }),
-            call: this.cephfsSubVolume.remove(
+            call: this.cephfsSubVolumeService.remove(
               this.fsName,
               this.selectedName,
               this.activeGroupName,
@@ -228,7 +234,7 @@ export class CephfsSubvolumeListComponent extends CdForm implements OnInit, OnCh
   getSubVolumes(subVolumeGroupName = '') {
     this.subVolumes$ = this.subject.pipe(
       switchMap(() =>
-        this.cephfsSubVolume.get(this.fsName, subVolumeGroupName).pipe(
+        this.cephfsSubVolumeService.get(this.fsName, subVolumeGroupName).pipe(
           catchError(() => {
             this.context.error();
             return of(null);
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.html
new file mode 100644 (file)
index 0000000..de31172
--- /dev/null
@@ -0,0 +1,36 @@
+<ng-container *ngIf="isLoading">
+  <cd-loading-panel>
+    <span i18n>Loading snapshots...</span>
+  </cd-loading-panel>
+</ng-container>
+
+<div class="row"
+     *ngIf="isSubVolumesAvailable; else noGroupsTpl">
+  <div class="col-sm-2">
+    <cd-vertical-navigation title="Groups"
+                            [items]="subvolumeGroupList"
+                            inputIdentifier="group-filter"
+                            (emitActiveItem)="selectSubVolumeGroup($event)"></cd-vertical-navigation>
+  </div>
+  <div class="col-sm-2 vertical-line"
+       *ngIf="subVolumes$ | async">
+    <cd-vertical-navigation title="Subvolumes"
+                            [items]="subVolumesList"
+                            (emitActiveItem)="selectSubVolume($event)"
+                            inputIdentifier="subvol-filter"></cd-vertical-navigation>
+  </div>
+  <div class="col-8 vertical-line"
+       *ngIf="isSubVolumesAvailable">
+    <cd-table [data]="snapshots$ | async"
+              columnMode="flex"
+              [columns]="columns"
+              selectionType="single"
+              [hasDetails]="false"
+              (fetchData)="fetchData()"></cd-table>
+  </div>
+</div>
+<ng-template #noGroupsTpl>
+  <cd-alert-panel type="info"
+                  i18n
+                  *ngIf="!isLoading">No subvolumes are present. Please create subvolumes to manage snapshots.</cd-alert-panel>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.spec.ts
new file mode 100644 (file)
index 0000000..1d03cf2
--- /dev/null
@@ -0,0 +1,38 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CephfsSubvolumeSnapshotsListComponent } from './cephfs-subvolume-snapshots-list.component';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { SharedModule } from '~/app/shared/shared.module';
+
+describe('CephfsSubvolumeSnapshotsListComponent', () => {
+  let component: CephfsSubvolumeSnapshotsListComponent;
+  let fixture: ComponentFixture<CephfsSubvolumeSnapshotsListComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [CephfsSubvolumeSnapshotsListComponent],
+      imports: [HttpClientTestingModule, SharedModule]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(CephfsSubvolumeSnapshotsListComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should show loading when the items are loading', () => {
+    component.isLoading = true;
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('cd-loading-panel')).toBeTruthy();
+  });
+
+  it('should show the alert panel when there are no subvolumes', () => {
+    component.isLoading = false;
+    component.subvolumeGroupList = [];
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('cd-alert-panel')).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component.ts
new file mode 100644 (file)
index 0000000..ef5c105
--- /dev/null
@@ -0,0 +1,148 @@
+import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
+import { Observable, ReplaySubject, forkJoin, of } from 'rxjs';
+import { catchError, shareReplay, switchMap, tap } from 'rxjs/operators';
+import { CephfsSubvolumeGroupService } from '~/app/shared/api/cephfs-subvolume-group.service';
+import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CephfsSubvolume, SubvolumeSnapshot } from '~/app/shared/models/cephfs-subvolume.model';
+
+@Component({
+  selector: 'cd-cephfs-subvolume-snapshots-list',
+  templateUrl: './cephfs-subvolume-snapshots-list.component.html',
+  styleUrls: ['./cephfs-subvolume-snapshots-list.component.scss']
+})
+export class CephfsSubvolumeSnapshotsListComponent implements OnInit, OnChanges {
+  @Input() fsName: string;
+
+  context: CdTableFetchDataContext;
+  columns: CdTableColumn[] = [];
+
+  subVolumes$: Observable<CephfsSubvolume[]>;
+  snapshots$: Observable<any[]>;
+  snapshotSubject = new ReplaySubject<SubvolumeSnapshot[]>();
+  subVolumeSubject = new ReplaySubject<CephfsSubvolume[]>();
+
+  subvolumeGroupList: string[] = [];
+  subVolumesList: string[];
+
+  activeGroupName = '';
+  activeSubVolumeName = '';
+
+  isSubVolumesAvailable = false;
+  isLoading = true;
+
+  observables: any = [];
+
+  constructor(
+    private cephfsSubvolumeGroupService: CephfsSubvolumeGroupService,
+    private cephfsSubvolumeService: CephfsSubvolumeService
+  ) {}
+
+  ngOnInit(): void {
+    this.columns = [
+      {
+        name: $localize`Name`,
+        prop: 'name',
+        flexGrow: 1
+      },
+      {
+        name: $localize`Created`,
+        prop: 'info.created_at',
+        flexGrow: 1,
+        cellTransformation: CellTemplate.timeAgo
+      },
+      {
+        name: $localize`Pending Clones`,
+        prop: 'info.has_pending_clones',
+        flexGrow: 0.5,
+        cellTransformation: CellTemplate.badge,
+        customTemplateConfig: {
+          map: {
+            no: { class: 'badge-success' },
+            yes: { class: 'badge-info' }
+          }
+        }
+      }
+    ];
+
+    this.cephfsSubvolumeGroupService
+      .get(this.fsName)
+      .pipe(
+        switchMap((groups) => {
+          // manually adding the group 'default' to the list.
+          groups.unshift({ name: '' });
+
+          const observables = groups.map((group) =>
+            this.cephfsSubvolumeService.existsInFs(this.fsName, group.name).pipe(
+              switchMap((resp) => {
+                if (resp) {
+                  this.subvolumeGroupList.push(group.name);
+                }
+                return of(resp); // Emit the response
+              })
+            )
+          );
+
+          return forkJoin(observables);
+        })
+      )
+      .subscribe(() => {
+        if (this.subvolumeGroupList.length) {
+          this.isSubVolumesAvailable = true;
+        }
+        this.isLoading = false;
+      });
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes.fsName) {
+      this.subVolumeSubject.next();
+    }
+  }
+
+  selectSubVolumeGroup(subVolumeGroupName: string) {
+    this.activeGroupName = subVolumeGroupName;
+    this.getSubVolumes();
+  }
+
+  selectSubVolume(subVolumeName: string) {
+    this.activeSubVolumeName = subVolumeName;
+    this.getSubVolumesSnapshot();
+  }
+
+  getSubVolumes() {
+    this.subVolumes$ = this.subVolumeSubject.pipe(
+      switchMap(() =>
+        this.cephfsSubvolumeService.get(this.fsName, this.activeGroupName, false).pipe(
+          tap((resp) => {
+            this.subVolumesList = resp.map((subVolume) => subVolume.name);
+            this.activeSubVolumeName = resp[0].name;
+            this.getSubVolumesSnapshot();
+          })
+        )
+      )
+    );
+  }
+
+  getSubVolumesSnapshot() {
+    this.snapshots$ = this.snapshotSubject.pipe(
+      switchMap(() =>
+        this.cephfsSubvolumeService
+          .getSnapshots(this.fsName, this.activeSubVolumeName, this.activeGroupName)
+          .pipe(
+            catchError(() => {
+              this.context.error();
+              return of(null);
+            })
+          )
+      ),
+      shareReplay(1)
+    );
+  }
+
+  fetchData() {
+    this.snapshotSubject.next();
+  }
+}
index 0ad69ccf50a335f2577247d4d88073a0aa1ed61a..6a50ad2e07869059868e7f2b06df832274e099dc 100644 (file)
         </cd-cephfs-subvolume-group>
       </ng-template>
     </ng-container>
+    <ng-container ngbNavItem="snapshots">
+      <a ngbNavLink
+         i18n>Snapshots</a>
+      <ng-template ngbNavContent>
+        <cd-cephfs-subvolume-snapshots-list [fsName]="selection.mdsmap.fs_name">
+        </cd-cephfs-subvolume-snapshots-list>
+      </ng-template>
+    </ng-container>
     <ng-container ngbNavItem="clients">
       <a ngbNavLink>
         <ng-container i18n>Clients</ng-container>
index a83e0f16870fcef7737f0f4c389b64232041dacf..cbdb2840867a18206de2575bdaf2b265985b55dd 100644 (file)
@@ -19,6 +19,7 @@ import { CephfsSubvolumeListComponent } from './cephfs-subvolume-list/cephfs-sub
 import { CephfsSubvolumeFormComponent } from './cephfs-subvolume-form/cephfs-subvolume-form.component';
 import { CephfsSubvolumeGroupComponent } from './cephfs-subvolume-group/cephfs-subvolume-group.component';
 import { CephfsSubvolumegroupFormComponent } from './cephfs-subvolumegroup-form/cephfs-subvolumegroup-form.component';
+import { CephfsSubvolumeSnapshotsListComponent } from './cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-list.component';
 
 @NgModule({
   imports: [
@@ -45,7 +46,8 @@ import { CephfsSubvolumegroupFormComponent } from './cephfs-subvolumegroup-form/
     CephfsSubvolumeFormComponent,
     CephfsDirectoriesComponent,
     CephfsSubvolumeGroupComponent,
-    CephfsSubvolumegroupFormComponent
+    CephfsSubvolumegroupFormComponent,
+    CephfsSubvolumeSnapshotsListComponent
   ]
 })
 export class CephfsModule {}
index db7fcfacd597e0ea49041b6d725747043d553be0..49d001f04f096ddabfb83f1d0183d43f33408a18 100644 (file)
@@ -1,9 +1,9 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 import { Observable, of } from 'rxjs';
-import { CephfsSubvolumeGroup } from '../models/cephfs-subvolumegroup.model';
 import _ from 'lodash';
 import { mapTo, catchError } from 'rxjs/operators';
+import { CephfsSubvolumeGroup } from '../models/cephfs-subvolume-group.model';
 
 @Injectable({
   providedIn: 'root'
@@ -13,8 +13,12 @@ export class CephfsSubvolumeGroupService {
 
   constructor(private http: HttpClient) {}
 
-  get(volName: string): Observable<CephfsSubvolumeGroup[]> {
-    return this.http.get<CephfsSubvolumeGroup[]>(`${this.baseURL}/${volName}`);
+  get(volName: string, info = true): Observable<CephfsSubvolumeGroup[]> {
+    return this.http.get<CephfsSubvolumeGroup[]>(`${this.baseURL}/${volName}`, {
+      params: {
+        info: info
+      }
+    });
   }
 
   create(
index e40e9a52f3f3fbc532ce861bc4cafd0be7fd8a89..2e8448ff1a22de149796f047dce08ff42de20974 100644 (file)
@@ -29,7 +29,7 @@ describe('CephfsSubvolumeService', () => {
 
   it('should call get', () => {
     service.get('testFS').subscribe();
-    const req = httpTesting.expectOne('api/cephfs/subvolume/testFS?group_name=');
+    const req = httpTesting.expectOne('api/cephfs/subvolume/testFS?group_name=&info=true');
     expect(req.request.method).toBe('GET');
   });
 
@@ -40,4 +40,12 @@ describe('CephfsSubvolumeService', () => {
     );
     expect(req.request.method).toBe('DELETE');
   });
+
+  it('should call getSnapshots', () => {
+    service.getSnapshots('testFS', 'testSubvol').subscribe();
+    const req = httpTesting.expectOne(
+      'api/cephfs/subvolume/snapshot/testFS/testSubvol?group_name='
+    );
+    expect(req.request.method).toBe('GET');
+  });
 });
index 4c167725007eac16f893a114933e38b29c6a5c2e..d76523aafd2adb6824a1789a46c817f89df7cab8 100644 (file)
@@ -1,6 +1,6 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
-import { CephfsSubvolume } from '../models/cephfs-subvolume.model';
+import { CephfsSubvolume, SubvolumeSnapshot } from '../models/cephfs-subvolume.model';
 import { Observable, of } from 'rxjs';
 import { catchError, mapTo } from 'rxjs/operators';
 import _ from 'lodash';
@@ -13,10 +13,11 @@ export class CephfsSubvolumeService {
 
   constructor(private http: HttpClient) {}
 
-  get(fsName: string, subVolumeGroupName: string = ''): Observable<CephfsSubvolume[]> {
+  get(fsName: string, subVolumeGroupName: string = '', info = true): Observable<CephfsSubvolume[]> {
     return this.http.get<CephfsSubvolume[]>(`${this.baseURL}/${fsName}`, {
       params: {
-        group_name: subVolumeGroupName
+        group_name: subVolumeGroupName,
+        info: info
       }
     });
   }
@@ -86,6 +87,14 @@ export class CephfsSubvolumeService {
     );
   }
 
+  existsInFs(fsName: string, groupName = ''): Observable<boolean> {
+    return this.http.get<boolean>(`${this.baseURL}/${fsName}/exists`, {
+      params: {
+        group_name: groupName
+      }
+    });
+  }
+
   update(fsName: string, subVolumeName: string, size: string, subVolumeGroupName: string = '') {
     return this.http.put(`${this.baseURL}/${fsName}`, {
       subvol_name: subVolumeName,
@@ -93,4 +102,19 @@ export class CephfsSubvolumeService {
       group_name: subVolumeGroupName
     });
   }
+
+  getSnapshots(
+    fsName: string,
+    subVolumeName: string,
+    groupName = ''
+  ): Observable<SubvolumeSnapshot[]> {
+    return this.http.get<SubvolumeSnapshot[]>(
+      `${this.baseURL}/snapshot/${fsName}/${subVolumeName}`,
+      {
+        params: {
+          group_name: groupName
+        }
+      }
+    );
+  }
 }
index 17f418d1e148f707dfc011dcf27d5fdd4e02dec5..142f19338b0100c4616687aa62c4db8dc2e4c899 100644 (file)
@@ -51,6 +51,7 @@ import { UsageBarComponent } from './usage-bar/usage-bar.component';
 import { WizardComponent } from './wizard/wizard.component';
 import { CardComponent } from './card/card.component';
 import { CardRowComponent } from './card-row/card-row.component';
+import { VerticalNavigationComponent } from './vertical-navigation/vertical-navigation.component';
 
 @NgModule({
   imports: [
@@ -105,7 +106,8 @@ import { CardRowComponent } from './card-row/card-row.component';
     CdLabelComponent,
     ColorClassFromTextPipe,
     CardComponent,
-    CardRowComponent
+    CardRowComponent,
+    VerticalNavigationComponent
   ],
   providers: [],
   exports: [
@@ -137,7 +139,8 @@ import { CardRowComponent } from './card-row/card-row.component';
     CustomLoginBannerComponent,
     CdLabelComponent,
     CardComponent,
-    CardRowComponent
+    CardRowComponent,
+    VerticalNavigationComponent
   ]
 })
 export class ComponentsModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.html
new file mode 100644 (file)
index 0000000..19628f0
--- /dev/null
@@ -0,0 +1,24 @@
+<ng-container *ngIf="items.length">
+  <h3 i18n
+      *ngIf="title">{{title}}</h3>
+  <input type="text"
+         placeholder="Filter by name..."
+         (keyup)="updateFilter()"
+         [id]="inputIdentifier"
+         class="form-control text-center mb-2">
+  <div class="overflow-auto">
+    <ul class="nav flex-column nav-pills">
+      <li class="nav-item"
+          *ngFor="let item of filteredItems; trackBy: trackByFn">
+        <a class="nav-link"
+           [class.active]="!activeItem"
+           (click)="selectItem()"
+           *ngIf="item === ''">Default</a>
+        <a class="nav-link text-decoration-none text-break"
+           [class.active]="item === activeItem"
+           (click)="selectItem(item)"
+           *ngIf="item !== ''">{{item}}</a>
+      </li>
+    </ul>
+  </div>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.scss
new file mode 100644 (file)
index 0000000..569e2d6
--- /dev/null
@@ -0,0 +1,3 @@
+.overflow-auto {
+  max-height: 50vh;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.spec.ts
new file mode 100644 (file)
index 0000000..0d45b33
--- /dev/null
@@ -0,0 +1,60 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { VerticalNavigationComponent } from './vertical-navigation.component';
+import { By } from '@angular/platform-browser';
+
+describe('VerticalNavigationComponent', () => {
+  let component: VerticalNavigationComponent;
+  let fixture: ComponentFixture<VerticalNavigationComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [VerticalNavigationComponent]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(VerticalNavigationComponent);
+    component = fixture.componentInstance;
+    component.items = ['item1', 'item2', 'item3'];
+    component.inputIdentifier = 'filter';
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should have a title', () => {
+    component.title = 'testTitle';
+    fixture.detectChanges();
+    const title = fixture.debugElement.query(By.css('h3'));
+    expect(title.nativeElement.textContent).toEqual('testTitle');
+  });
+
+  it('should select the first item as active if no item is selected', () => {
+    expect(component.activeItem).toEqual('item1');
+  });
+
+  it('should filter the items by the keyword in filter input', () => {
+    const event = new KeyboardEvent('keyup');
+    const filterInput = fixture.debugElement.query(By.css('#filter'));
+    filterInput.nativeElement.value = 'item1';
+    filterInput.nativeElement.dispatchEvent(event);
+    fixture.detectChanges();
+    expect(component.filteredItems).toEqual(['item1']);
+
+    filterInput.nativeElement.value = 'item2';
+    filterInput.nativeElement.dispatchEvent(event);
+    fixture.detectChanges();
+    expect(component.filteredItems).toEqual(['item2']);
+  });
+
+  it('should select the item when clicked', () => {
+    component.activeItem = '';
+
+    // click on the first item in the nav list
+    const item = fixture.debugElement.query(By.css('.nav-link'));
+    item.nativeElement.click();
+    fixture.detectChanges();
+    expect(component.activeItem).toEqual('item1');
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/vertical-navigation/vertical-navigation.component.ts
new file mode 100644 (file)
index 0000000..a46cc4f
--- /dev/null
@@ -0,0 +1,37 @@
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+
+@Component({
+  selector: 'cd-vertical-navigation',
+  templateUrl: './vertical-navigation.component.html',
+  styleUrls: ['./vertical-navigation.component.scss']
+})
+export class VerticalNavigationComponent implements OnInit {
+  @Input() items: string[];
+  @Input() title: string;
+  @Input() inputIdentifier: string;
+
+  @Output() emitFilteredItems: EventEmitter<string[]> = new EventEmitter();
+  @Output() emitActiveItem: EventEmitter<string> = new EventEmitter();
+
+  activeItem = '';
+  filteredItems: string[];
+
+  ngOnInit(): void {
+    this.filteredItems = this.items;
+    if (!this.activeItem && this.items.length) this.selectItem(this.items[0]);
+  }
+
+  updateFilter() {
+    const filterInput = document.getElementById(this.inputIdentifier) as HTMLInputElement;
+    this.filteredItems = this.items.filter((item) => item.includes(filterInput.value));
+  }
+
+  selectItem(item = '') {
+    this.activeItem = item;
+    this.emitActiveItem.emit(item);
+  }
+
+  trackByFn(item: number) {
+    return item;
+  }
+}
index fc087ab53d00c5f59b430c6950ed5a1d9bae00ca..246e4543eb9d279f552b869497d788759cc1eb82 100644 (file)
@@ -1,6 +1,6 @@
 export interface CephfsSubvolumeGroup {
   name: string;
-  info: CephfsSubvolumeGroupInfo;
+  info?: CephfsSubvolumeGroupInfo;
 }
 
 export interface CephfsSubvolumeGroupInfo {
index 41858be613042d09d22819fc52e34c2bc839e6ce..25a2a5acc7f49978415185da031d02f96acae75a 100644 (file)
@@ -16,3 +16,13 @@ export interface CephfsSubvolumeInfo {
   gid: number;
   pool_namespace: string;
 }
+
+export interface SubvolumeSnapshot {
+  name: string;
+  info: SubvolumeSnapshotInfo;
+}
+
+export interface SubvolumeSnapshotInfo {
+  created_at: string;
+  has_pending_clones: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-subvolumegroup.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-subvolumegroup.model.ts
deleted file mode 100644 (file)
index fc087ab..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-export interface CephfsSubvolumeGroup {
-  name: string;
-  info: CephfsSubvolumeGroupInfo;
-}
-
-export interface CephfsSubvolumeGroupInfo {
-  mode: number;
-  bytes_pcent: number;
-  bytes_quota: number;
-  data_pool: string;
-  state: string;
-  created_at: string;
-}
index aeb5d94643903f3e4154946dfc0998d5a7b5e9d5..04996e80a5d1060b7b0ebbe3b53ab79d4128366b 100644 (file)
@@ -1859,6 +1859,11 @@ paths:
         required: true
         schema:
           type: string
+      - default: true
+        in: query
+        name: info
+        schema:
+          type: boolean
       responses:
         '200':
           content:
@@ -1954,6 +1959,48 @@ paths:
       - jwt: []
       tags:
       - CephfsSubvolumeGroup
+  /api/cephfs/subvolume/snapshot/{vol_name}/{subvol_name}:
+    get:
+      parameters:
+      - in: path
+        name: vol_name
+        required: true
+        schema:
+          type: string
+      - in: path
+        name: subvol_name
+        required: true
+        schema:
+          type: string
+      - default: ''
+        in: query
+        name: group_name
+        schema:
+          type: string
+      - default: true
+        in: query
+        name: info
+        schema:
+          type: boolean
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - CephfsSubvolumeSnapshot
   /api/cephfs/subvolume/{vol_name}:
     delete:
       parameters:
@@ -2013,6 +2060,11 @@ paths:
         name: group_name
         schema:
           type: string
+      - default: true
+        in: query
+        name: info
+        schema:
+          type: boolean
       responses:
         '200':
           content:
@@ -2079,6 +2131,38 @@ paths:
       - jwt: []
       tags:
       - CephFSSubvolume
+  /api/cephfs/subvolume/{vol_name}/exists:
+    get:
+      parameters:
+      - in: path
+        name: vol_name
+        required: true
+        schema:
+          type: string
+      - default: ''
+        in: query
+        name: group_name
+        schema:
+          type: string
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - CephFSSubvolume
   /api/cephfs/subvolume/{vol_name}/info:
     get:
       parameters:
@@ -12611,6 +12695,8 @@ tags:
   name: Cephfs
 - description: Cephfs Subvolume Group Management API
   name: CephfsSubvolumeGroup
+- description: Cephfs Subvolume Snapshot Management API
+  name: CephfsSubvolumeSnapshot
 - description: Get Cluster Details
   name: Cluster
 - description: Manage Cluster Configurations