]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: RBD management
authorRicardo Marques <rimarques@suse.com>
Thu, 22 Mar 2018 12:41:45 +0000 (12:41 +0000)
committerRicardo Marques <rimarques@suse.com>
Wed, 18 Apr 2018 11:04:08 +0000 (12:04 +0100)
Signed-off-by: Ricardo Marques <rimarques@suse.com>
46 files changed:
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-create-request.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-response.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/api.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/empty.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/empty.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/unix_errno.enum.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts

index 8883796d367dbb2a952d9caab36751eb3b60a7f8..1b3c15bb28540209f61bb3bf954f01301a1a9706 100644 (file)
@@ -3,7 +3,8 @@ import { RouterModule, Routes } from '@angular/router';
 
 import { IscsiComponent } from './ceph/block/iscsi/iscsi.component';
 import { MirroringComponent } from './ceph/block/mirroring/mirroring.component';
-import { PoolDetailComponent } from './ceph/block/pool-detail/pool-detail.component';
+import { RbdFormComponent } from './ceph/block/rbd-form/rbd-form.component';
+import { RbdListComponent } from './ceph/block/rbd-list/rbd-list.component';
 import { CephfsComponent } from './ceph/cephfs/cephfs/cephfs.component';
 import { ClientsComponent } from './ceph/cephfs/clients/clients.component';
 import { ConfigurationComponent } from './ceph/cluster/configuration/configuration.component';
@@ -31,7 +32,9 @@ const routes: Routes = [
     canActivate: [AuthGuardService]
   },
   { path: 'block/iscsi', component: IscsiComponent, canActivate: [AuthGuardService] },
-  { path: 'block/pool/:name', component: PoolDetailComponent, canActivate: [AuthGuardService] },
+  { path: 'block/rbd', component: RbdListComponent, canActivate: [AuthGuardService] },
+  { path: 'rbd/add', component: RbdFormComponent, canActivate: [AuthGuardService] },
+  { path: 'rbd/edit/:pool/:name', component: RbdFormComponent, canActivate: [AuthGuardService] },
   {
     path: 'perf_counters/:type/:id',
     component: PerformanceCounterComponent,
index bd799d17633803568ccc308f480290a38caf99a5..61eebd1682f3ab7e3e058e06434c3749a4dd42d1 100644 (file)
@@ -1,29 +1,52 @@
 import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
-import { FormsModule } from '@angular/forms';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
 
+import { BsDropdownModule, ModalModule, TabsModule, TooltipModule } from 'ngx-bootstrap';
 import { ProgressbarModule } from 'ngx-bootstrap/progressbar';
-import { TabsModule } from 'ngx-bootstrap/tabs';
 
 import { SharedModule } from '../../shared/shared.module';
 import { IscsiComponent } from './iscsi/iscsi.component';
 import { MirrorHealthColorPipe } from './mirror-health-color.pipe';
 import { MirroringComponent } from './mirroring/mirroring.component';
-import { PoolDetailComponent } from './pool-detail/pool-detail.component';
+import { RbdDetailsComponent } from './rbd-details/rbd-details.component';
+import { RbdFormComponent } from './rbd-form/rbd-form.component';
+import { RbdListComponent } from './rbd-list/rbd-list.component';
+import { RbdSnapshotFormComponent } from './rbd-snapshot-form/rbd-snapshot-form.component';
+import { RbdSnapshotListComponent } from './rbd-snapshot-list/rbd-snapshot-list.component';
+import {
+  RollbackConfirmationModalComponent
+} from './rollback-confirmation-modal/rollback-confimation-modal.component';
 
 @NgModule({
+  entryComponents: [
+    RbdDetailsComponent,
+    RbdSnapshotFormComponent,
+    RollbackConfirmationModalComponent
+  ],
   imports: [
     CommonModule,
     FormsModule,
+    ReactiveFormsModule,
     TabsModule.forRoot(),
     ProgressbarModule.forRoot(),
-    SharedModule
+    BsDropdownModule.forRoot(),
+    TooltipModule.forRoot(),
+    ModalModule.forRoot(),
+    SharedModule,
+    RouterModule
   ],
   declarations: [
-    PoolDetailComponent,
+    RbdListComponent,
     IscsiComponent,
     MirroringComponent,
-    MirrorHealthColorPipe
+    MirrorHealthColorPipe,
+    RbdDetailsComponent,
+    RbdFormComponent,
+    RbdSnapshotListComponent,
+    RbdSnapshotFormComponent,
+    RollbackConfirmationModalComponent
   ]
 })
 export class BlockModule { }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.html
deleted file mode 100644 (file)
index 5a1b0bb..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-<nav aria-label="breadcrumb">
-  <ol class="breadcrumb">
-    <li i18n
-        class="breadcrumb-item">Block</li>
-    <li i18n
-        class="breadcrumb-item">Pools</li>
-    <li class="breadcrumb-item active"
-        aria-current="page">{{ name }}</li>
-  </ol>
-</nav>
-
-<cd-view-cache [status]="viewCacheStatus"></cd-view-cache>
-
-<cd-table [data]="images"
-          columnMode="flex"
-          [columns]="columns"
-          (fetchData)="loadImages()">
-</cd-table>
-
-<ng-template #parentTpl
-             let-value="value">
-  <span *ngIf="value">{{ value.pool_name }}/{{ value.image_name }}@{{ value.snap_name }}</span>
-  <span *ngIf="!value">-</span>
-</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.scss
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.spec.ts
deleted file mode 100644 (file)
index aea790c..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
-import { RouterTestingModule } from '@angular/router/testing';
-
-import { AlertModule, BsDropdownModule, TabsModule } from 'ngx-bootstrap';
-
-import { ComponentsModule } from '../../../shared/components/components.module';
-import { SharedModule } from '../../../shared/shared.module';
-import { PoolDetailComponent } from './pool-detail.component';
-
-describe('PoolDetailComponent', () => {
-  let component: PoolDetailComponent;
-  let fixture: ComponentFixture<PoolDetailComponent>;
-
-  beforeEach(async(() => {
-    TestBed.configureTestingModule({
-      imports: [
-        SharedModule,
-        BsDropdownModule.forRoot(),
-        TabsModule.forRoot(),
-        AlertModule.forRoot(),
-        ComponentsModule,
-        RouterTestingModule,
-        HttpClientTestingModule
-      ],
-      declarations: [ PoolDetailComponent ]
-    })
-    .compileComponents();
-  }));
-
-  beforeEach(() => {
-    fixture = TestBed.createComponent(PoolDetailComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/pool-detail/pool-detail.component.ts
deleted file mode 100644 (file)
index 018758a..0000000
+++ /dev/null
@@ -1,96 +0,0 @@
-import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
-
-import { PoolService } from '../../../shared/api/pool.service';
-import { ViewCacheStatus } from '../../../shared/enum/view-cache-status.enum';
-import { CdTableColumn } from '../../../shared/models/cd-table-column';
-import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
-import { DimlessPipe } from '../../../shared/pipes/dimless.pipe';
-
-@Component({
-  selector: 'cd-pool-detail',
-  templateUrl: './pool-detail.component.html',
-  styleUrls: ['./pool-detail.component.scss']
-})
-export class PoolDetailComponent implements OnInit, OnDestroy {
-  @ViewChild('parentTpl') parentTpl: TemplateRef<any>;
-
-  name: string;
-  images: any;
-  columns: CdTableColumn[];
-  retries: number;
-  routeParamsSubscribe: any;
-  viewCacheStatus: ViewCacheStatus;
-
-  constructor(
-    private route: ActivatedRoute,
-    private poolService: PoolService,
-    dimlessBinaryPipe: DimlessBinaryPipe,
-    dimlessPipe: DimlessPipe
-  ) {
-    this.columns = [
-      {
-        name: 'Name',
-        prop: 'name',
-        cellTemplate: this.parentTpl,
-        flexGrow: 2
-      },
-      {
-        name: 'Size',
-        prop: 'size',
-        flexGrow: 1,
-        cellClass: 'text-right',
-        pipe: dimlessBinaryPipe
-      },
-      {
-        name: 'Objects',
-        prop: 'num_objs',
-        flexGrow: 1,
-        cellClass: 'text-right',
-        pipe: dimlessPipe
-      },
-      {
-        name: 'Object size',
-        prop: 'obj_size',
-        flexGrow: 1,
-        cellClass: 'text-right',
-        pipe: dimlessBinaryPipe
-      },
-      {
-        name: 'Features',
-        prop: 'features_name',
-        flexGrow: 3
-      },
-      {
-        name: 'Parent',
-        prop: 'parent',
-        cellTemplate: this.parentTpl,
-        flexGrow: 2
-      }
-    ];
-  }
-
-  ngOnInit() {
-    this.routeParamsSubscribe = this.route.params.subscribe((params: { name: string }) => {
-      this.name = params.name;
-      this.images = [];
-      this.retries = 0;
-    });
-  }
-
-  ngOnDestroy() {
-    this.routeParamsSubscribe.unsubscribe();
-  }
-
-  loadImages() {
-    this.poolService.rbdPoolImages(this.name).then(
-      resp => {
-        this.viewCacheStatus = resp[0].status;
-        this.images = resp[0].value;
-      },
-      () => {
-        this.viewCacheStatus = ViewCacheStatus.ValueException;
-      }
-    );
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.html
new file mode 100644 (file)
index 0000000..655154a
--- /dev/null
@@ -0,0 +1,136 @@
+<ng-template #usageNotAvailableTooltipTpl>
+  <div [innerHtml]="'Only available for RBD images with <strong>fast-diff</strong> enabled'"></div>
+</ng-template>
+
+
+<tabset *ngIf="selection?.hasSingleSelection">
+  <tab i18n-heading
+       heading="Details">
+    <table class="table table-striped table-bordered">
+      <tbody>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Name
+        </td>
+        <td class="col-sm-3">{{ selectedItem.name }}</td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Pool
+        </td>
+        <td class="col-sm-3">{{ selectedItem.pool_name }}</td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Data Pool
+        </td>
+        <td class="col-sm-3">{{ selectedItem.data_pool | empty }}</td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Created
+        </td>
+        <td class="col-sm-3">{{ selectedItem.timestamp | cdDate }}</td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Size
+        </td>
+        <td class="col-sm-3">{{ selectedItem.size | dimlessBinary }}</td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Objects
+        </td>
+        <td class="col-sm-3">{{ selectedItem.num_objs | dimless }}</td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Object size
+        </td>
+        <td class="col-sm-3">{{ selectedItem.obj_size | dimlessBinary }}</td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Features
+        </td>
+        <td class="col-sm-3">
+          <span *ngFor="let feature of selectedItem.features_name">
+            <span class="badge badge-pill badge-primary">{{ feature }}</span>
+          </span>
+        </td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Provisioned
+        </td>
+        <td class="col-sm-3">
+          <span *ngIf="selectedItem.features_name?.indexOf('fast-diff') === -1">
+          <span class="text-muted"
+                [tooltip]="usageNotAvailableTooltipTpl"
+                placement="right">N/A</span>
+        </span>
+          <span *ngIf="selectedItem.features_name?.indexOf('fast-diff') !== -1">
+          {{ selectedItem.disk_usage | dimlessBinary }}
+        </span>
+        </td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Total provisioned
+        </td>
+        <td class="col-sm-3">
+          <span *ngIf="selectedItem.features_name?.indexOf('fast-diff') === -1">
+          <span class="text-muted"
+                [tooltip]="usageNotAvailableTooltipTpl"
+                placement="right">N/A</span>
+        </span>
+          <span *ngIf="selectedItem.features_name?.indexOf('fast-diff') !== -1">
+          {{ selectedItem.total_disk_usage | dimlessBinary }}
+        </span>
+        </td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Striping unit
+        </td>
+        <td class="col-sm-3">{{ selectedItem.stripe_unit | dimlessBinary }}</td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Striping count
+        </td>
+        <td class="col-sm-3">{{ selectedItem.stripe_count }}</td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Parent
+        </td>
+        <td class="col-sm-3">
+          <span *ngIf="selectedItem.parent">{{ selectedItem.parent.pool_name }}/{{ selectedItem.parent.image_name }}@{{ selectedItem.parent.snap_name }}</span>
+          <span *ngIf="!selectedItem.parent">-</span>
+        </td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Block name prefix
+        </td>
+        <td class="col-sm-3">{{ selectedItem.block_name_prefix }}</td>
+      </tr>
+      <tr>
+        <td i18n
+            class="bold col-sm-1">Order
+        </td>
+        <td class="col-sm-3">{{ selectedItem.order }}</td>
+      </tr>
+      </tbody>
+    </table>
+  </tab>
+  <tab i18n-heading
+       heading="Snapshots">
+    <cd-rbd-snapshot-list [snapshots]="selectedItem.snapshots"
+                          [poolName]="selectedItem.pool_name"
+                          [rbdName]="selectedItem.name"
+                          [executingTasks]="selectedItem.executingTasks"></cd-rbd-snapshot-list>
+  </tab>
+</tabset>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.spec.ts
new file mode 100644 (file)
index 0000000..efca570
--- /dev/null
@@ -0,0 +1,30 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TabsModule, TooltipModule } from 'ngx-bootstrap';
+
+import { SharedModule } from '../../../shared/shared.module';
+import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component';
+import { RbdDetailsComponent } from './rbd-details.component';
+
+describe('RbdDetailsComponent', () => {
+  let component: RbdDetailsComponent;
+  let fixture: ComponentFixture<RbdDetailsComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ RbdDetailsComponent, RbdSnapshotListComponent ],
+      imports: [ SharedModule, TabsModule.forRoot(), TooltipModule.forRoot(), ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RbdDetailsComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-details/rbd-details.component.ts
new file mode 100644 (file)
index 0000000..896e295
--- /dev/null
@@ -0,0 +1,22 @@
+import { Component, Input, OnChanges } from '@angular/core';
+
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+
+@Component({
+  selector: 'cd-rbd-details',
+  templateUrl: './rbd-details.component.html',
+  styleUrls: ['./rbd-details.component.scss']
+})
+export class RbdDetailsComponent implements OnChanges {
+
+  @Input() selection: CdTableSelection;
+  selectedItem: any;
+
+  constructor() { }
+
+  ngOnChanges() {
+    if (this.selection.hasSelection) {
+      this.selectedItem = this.selection.first();
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-create-request.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-create-request.model.ts
new file mode 100644 (file)
index 0000000..2a2366f
--- /dev/null
@@ -0,0 +1,5 @@
+import { RbdFormModel } from './rbd-form.model';
+
+export class RbdFormCreateRequestModel extends RbdFormModel {
+  features: Array<string> = [];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-edit-request.model.ts
new file mode 100644 (file)
index 0000000..3949563
--- /dev/null
@@ -0,0 +1,5 @@
+export class RbdFormEditRequestModel {
+  name: string;
+  size: number;
+  features: Array<string> = [];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-response.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form-response.model.ts
new file mode 100644 (file)
index 0000000..112d060
--- /dev/null
@@ -0,0 +1,5 @@
+import { RbdFormModel } from './rbd-form.model';
+
+export class RbdFormResponseModel extends RbdFormModel {
+  features_name: string[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html
new file mode 100644 (file)
index 0000000..19f745f
--- /dev/null
@@ -0,0 +1,314 @@
+<nav aria-label="breadcrumb">
+  <ol class="breadcrumb">
+    <li class="breadcrumb-item">Block</li>
+    <li class="breadcrumb-item">
+      <a routerLink="/block/rbd">Images</a></li>
+    <li class="breadcrumb-item active"
+        i18n>{editing, select, 1 {Edit} other {Add}}</li>
+  </ol>
+</nav>
+
+<div class="col-sm-12 col-lg-6">
+  <form name="rbdForm"
+        class="form-horizontal"
+        #formDir="ngForm"
+        [formGroup]="rbdForm"
+        novalidate>
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title">
+          <span i18n>{editing, select, 1 {Edit} other {Add}}</span> RBD
+        </h3>
+      </div>
+      <div class="panel-body">
+
+        <!-- Name -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (formDir.submitted || rbdForm.controls.name.dirty) && rbdForm.controls.name.invalid}">
+          <label i18n
+                 class="control-label col-sm-3"
+                 for="name">Name
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <input class="form-control"
+                   type="text"
+                   placeholder="Name..."
+                   id="name"
+                   name="name"
+                   formControlName="name"
+                   autofocus>
+            <span i18n
+                  class="help-block"
+                  *ngIf="(formDir.submitted || rbdForm.controls.name.dirty) && rbdForm.controls.name.hasError('required')">
+              This field is required.
+            </span>
+          </div>
+        </div>
+
+        <!-- Pool -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (formDir.submitted || rbdForm.controls.pool.dirty) && rbdForm.controls.pool.invalid}"
+             (change)="onPoolChange($event.target.value)">
+          <label class="control-label col-sm-3"
+                 for="pool">
+            Pool
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <select id="pool"
+                    name="pool"
+                    class="form-control"
+                    formControlName="pool">
+              <option *ngIf="pools === null"
+                      [ngValue]="null">Loading...
+              </option>
+              <option *ngIf="pools !== null && pools.length === 0"
+                      [ngValue]="null">-- No pools available --
+              </option>
+              <option *ngIf="pools !== null && pools.length > 0"
+                      [ngValue]="null">-- Select a pool --
+              </option>
+              <option *ngFor="let pool of pools"
+                      [value]="pool.pool_name">{{ pool.pool_name }}</option>
+            </select>
+            <span i18n
+                  class="help-block"
+                  *ngIf="(formDir.submitted || rbdForm.controls.pool.dirty) && rbdForm.controls.pool.hasError('required')">
+              This field is required.
+            </span>
+          </div>
+        </div>
+
+        <!-- Use a dedicated pool -->
+        <div class="form-group">
+          <div class="col-sm-offset-3 col-sm-9">
+            <div class="checkbox checkbox-primary">
+              <input type="checkbox"
+                     id="useDataPool"
+                     name="useDataPool"
+                     formControlName="useDataPool"
+                     (change)="onUseDataPoolChange()">
+              <label i18n
+                     for="useDataPool">Use a dedicated data pool</label>
+            </div>
+          </div>
+        </div>
+
+        <!-- Data Pool -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (formDir.submitted || rbdForm.controls.dataPool.dirty) && rbdForm.controls.dataPool.invalid}"
+             *ngIf="rbdForm.controls.useDataPool.value">
+          <label class="control-label col-sm-3"
+                 for="dataPool">
+            Data pool
+            <span class="required"></span>
+            <cd-helper i18n-html
+                       html="Dedicated pool that stores the object-data of the RBD.">
+            </cd-helper>
+          </label>
+          <div class="col-sm-9">
+            <select id="dataPool"
+                    name="dataPool"
+                    class="form-control"
+                    formControlName="dataPool"
+                    (change)="onDataPoolChange($event.target.value)">
+              <option *ngIf="dataPools === null"
+                      [ngValue]="null">Loading...
+              </option>
+              <option *ngIf="dataPools !== null && dataPools.length === 0"
+                      [ngValue]="null">-- No data pools available --
+              </option>
+              <option *ngIf="dataPools !== null && dataPools.length > 0"
+                      [ngValue]="null">-- Select a data pool --
+              </option>
+              <option *ngFor="let dataPool of dataPools"
+                      [value]="dataPool.pool_name">{{ dataPool.pool_name }}</option>
+            </select>
+            <span i18n
+                  class="help-block"
+                  *ngIf="(formDir.submitted || rbdForm.controls.dataPool.dirty) && rbdForm.controls.dataPool.hasError('required')">
+              This field is required.
+            </span>
+          </div>
+        </div>
+
+        <!-- Size -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (formDir.submitted || rbdForm.controls.size.dirty) && rbdForm.controls.size.invalid}">
+          <label i18n
+                 class="control-label col-sm-3"
+                 for="size">Size
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <input id="size"
+                   name="size"
+                   class="form-control"
+                   type="text"
+                   formControlName="size"
+                   Ã¬18n-placeholder
+                   placeholder="e.g., 10GiB"
+                   defaultUnit="GiB"
+                   cdDimlessBinary>
+            <span i18n
+                  class="help-block"
+                  *ngIf="(formDir.submitted || rbdForm.controls.size.dirty) && rbdForm.controls.size.hasError('required')">
+              This field is required.
+            </span>
+            <span i18n
+                  class="help-block"
+                  *ngIf="(formDir.submitted || rbdForm.controls.size.dirty) && rbdForm.controls.size.hasError('invalidSizeObject')">
+              You have to increase the size.
+            </span>
+          </div>
+        </div>
+
+        <!-- Features -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (formDir.submitted || featuresFormGroups.dirty) && featuresFormGroups.invalid}"
+             formGroupName="features">
+          <label i18n
+                 class="col-sm-3 control-label"
+                 for="features">
+            Features
+          </label>
+          <div class="col-sm-9">
+            <div class="checkbox checkbox-primary"
+                 *ngIf="!editing">
+              <input type="checkbox"
+                     id="default-features"
+                     name="default-features"
+                     formControlName="defaultFeatures">
+              <label i18n
+                     for="default-features">Use default features</label>
+            </div>
+            <div *ngIf="!featuresFormGroups.value.defaultFeatures">
+              <br *ngIf="!editing">
+              <div class="checkbox checkbox-primary"
+                   *ngFor="let feature of featuresList">
+                <input type="checkbox"
+                       id="{{ feature.key }}"
+                       name="{{ feature.key }}"
+                       formControlName="{{ feature.key }}">
+                <label for="{{ feature.key }}">{{ feature.desc }}</label>
+                <cd-helper *ngIf="feature.helperHtml"
+                           html="{{ feature.helperHtml }}">
+                </cd-helper>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Advanced -->
+        <div class="row">
+          <div class="col-sm-12">
+            <a i18n
+               class="pull-right margin-right-md"
+               (click)="advancedEnabled = true"
+               *ngIf="!advancedEnabled">
+              Advanced...
+            </a>
+          </div>
+        </div>
+        <div *ngIf="advancedEnabled">
+
+          <h2 i18n
+              class="page-header">Advanced</h2>
+
+          <!-- Object Size -->
+          <div class="form-group"
+               [ngClass]="{'has-error': (formDir.submitted || rbdForm.controls.obj_size.dirty) && rbdForm.controls.obj_size.invalid}">
+            <label i18n
+                   class="control-label col-sm-3"
+                   for="size">Object size
+            </label>
+            <div class="col-sm-9">
+              <select id="obj_size"
+                    name="obj_size"
+                    class="form-control"
+                    formControlName="obj_size">
+                <option *ngFor="let objectSize of objectSizes"
+                        [value]="objectSize">{{ objectSize }}</option>
+              </select>
+            </div>
+          </div>
+
+          <!-- Strippe Unit -->
+          <div class="form-group"
+               [ngClass]="{'has-error': (formDir.submitted || rbdForm.controls.stripingUnit.dirty) && rbdForm.controls.stripingUnit.invalid}">
+            <label class="control-label col-sm-3"
+                   for="stripingUnit">
+              <span i18n>Stripe unit</span><span class="required"
+                                 *ngIf="rbdForm.controls.stripingCount.value !== null"></span>
+            </label>
+            <div class="col-sm-9">
+              <select id="stripingUnit"
+                    name="stripingUnit"
+                    class="form-control"
+                    formControlName="stripingUnit">
+                <option i18n [ngValue]="null">-- Select stripe unit --</option>
+                <option *ngFor="let objectSize of objectSizes"
+                        [value]="objectSize">{{ objectSize }}</option>
+              </select>
+              <span i18n
+                    class="help-block"
+                    *ngIf="(formDir.submitted || rbdForm.controls.stripingUnit.dirty) && rbdForm.controls.stripingUnit.hasError('required')">
+                This field is required because stripe count is defined!
+              </span>
+              <span i18n
+                  class="help-block"
+                  *ngIf="(formDir.submitted || rbdForm.controls.stripingUnit.dirty) && rbdForm.controls.stripingUnit.hasError('invalidStripingUnit')">
+              Stripe unit is greater than object size.
+            </span>
+            </div>
+          </div>
+
+          <!-- Strippe Count -->
+          <div class="form-group"
+               [ngClass]="{'has-error': (formDir.submitted || rbdForm.controls.stripingCount.dirty) && rbdForm.controls.stripingCount.invalid}">
+            <label class="control-label col-sm-3"
+                   for="stripingCount">
+              <span i18n>Stripe count</span><span class="required"
+                                  *ngIf="rbdForm.controls.stripingUnit.value !== null"></span>
+            </label>
+            <div class="col-sm-9">
+              <input id="stripingCount"
+                     name="stripingCount"
+                     formControlName="stripingCount"
+                     class="form-control"
+                     type="number">
+              <span i18n
+                    class="help-block"
+                    *ngIf="(formDir.submitted || rbdForm.controls.stripingCount.dirty) && rbdForm.controls.stripingCount.hasError('required')">
+                This field is required because stripe unit is defined!
+              </span>
+              <span i18n
+                    class="help-block"
+                    *ngIf="(formDir.submitted || rbdForm.controls.stripingCount.dirty) && rbdForm.controls.stripingCount.hasError('min')">
+                Stripe count must be greater than 0.
+              </span>
+            </div>
+          </div>
+
+        </div>
+
+      </div>
+      <div class="panel-footer">
+        <div class="button-group text-right">
+          <cd-submit-button [form]="rbdForm"
+                            type="button"
+                            (submitAction)="submit()">
+            <span i18n>{editing, select, 1 {Update} other {Create}}</span> RBD
+          </cd-submit-button>
+          <button i18n
+                  type="button"
+                  class="btn btn-sm btn-default"
+                  routerLink="/block/rbd">
+            Back
+          </button>
+        </div>
+      </div>
+    </div>
+  </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.spec.ts
new file mode 100644 (file)
index 0000000..8c7c3fc
--- /dev/null
@@ -0,0 +1,42 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+
+import { ApiModule } from '../../../shared/api/api.module';
+import { ComponentsModule } from '../../../shared/components/components.module';
+import { ServicesModule } from '../../../shared/services/services.module';
+import { RbdFormComponent } from './rbd-form.component';
+
+describe('RbdFormComponent', () => {
+  let component: RbdFormComponent;
+  let fixture: ComponentFixture<RbdFormComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      imports: [
+        ReactiveFormsModule,
+        HttpClientTestingModule,
+        RouterTestingModule,
+        ComponentsModule,
+        ServicesModule,
+        ApiModule,
+        ToastModule.forRoot()
+      ],
+      declarations: [ RbdFormComponent ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RbdFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.ts
new file mode 100644 (file)
index 0000000..c6add11
--- /dev/null
@@ -0,0 +1,485 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, FormGroup, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import * as _ from 'lodash';
+
+import { PoolService } from '../../../shared/api/pool.service';
+import { RbdService } from '../../../shared/api/rbd.service';
+import { NotificationType } from '../../../shared/enum/notification-type.enum';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import { FormatterService } from '../../../shared/services/formatter.service';
+import { NotificationService } from '../../../shared/services/notification.service';
+import { TaskManagerMessageService } from '../../../shared/services/task-manager-message.service';
+import { TaskManagerService } from '../../../shared/services/task-manager.service';
+import { RbdFormCreateRequestModel } from './rbd-form-create-request.model';
+import { RbdFormEditRequestModel } from './rbd-form-edit-request.model';
+import { RbdFormResponseModel } from './rbd-form-response.model';
+
+@Component({
+  selector: 'cd-rbd-form',
+  templateUrl: './rbd-form.component.html',
+  styleUrls: ['./rbd-form.component.scss']
+})
+export class RbdFormComponent implements OnInit {
+
+  rbdForm: FormGroup;
+  featuresFormGroups: FormGroup;
+  defaultFeaturesFormControl: FormControl;
+  deepFlattenFormControl: FormControl;
+  layeringFormControl: FormControl;
+  exclusiveLockFormControl: FormControl;
+  objectMapFormControl: FormControl;
+  journalingFormControl: FormControl;
+  fastDiffFormControl: FormControl;
+
+  pools: Array<string> = null;
+  allPools: Array<string> = null;
+  dataPools: Array<string> = null;
+  allDataPools: Array<string> = null;
+  features: any;
+  featuresList = [];
+
+  routeParamsSubscribe: any;
+  pool: string;
+
+  advancedEnabled = false;
+
+  editing = false;
+
+  response: RbdFormResponseModel;
+
+  defaultObjectSize = '4MiB';
+
+  objectSizes: Array<string> = [
+    '4KiB',
+    '8KiB',
+    '16KiB',
+    '32KiB',
+    '64KiB',
+    '128KiB',
+    '256KiB',
+    '512KiB',
+    '1MiB',
+    '2MiB',
+    '4MiB',
+    '8MiB',
+    '16MiB',
+    '32MiB'
+  ];
+
+  constructor(private route: ActivatedRoute,
+              private router: Router,
+              private poolService: PoolService,
+              private rbdService: RbdService,
+              private formatter: FormatterService,
+              private dimlessBinaryPipe: DimlessBinaryPipe,
+              private taskManagerService: TaskManagerService,
+              private taskManagerMessageService: TaskManagerMessageService,
+              private notificationService: NotificationService) {
+    this.features = {
+      'deep-flatten': {
+        desc: 'Deep flatten',
+        requires: null,
+        allowEnable: false,
+        allowDisable: true
+      },
+      'layering': {
+        desc: 'Layering',
+        requires: null,
+        allowEnable: false,
+        allowDisable: false
+      },
+      'exclusive-lock': {
+        desc: 'Exclusive lock',
+        requires: null,
+        allowEnable: true,
+        allowDisable: true
+      },
+      'object-map': {
+        desc: 'Object map (requires exclusive-lock)',
+        requires: 'exclusive-lock',
+        allowEnable: true,
+        allowDisable: true
+      },
+      'journaling': {
+        desc: 'Journaling (requires exclusive-lock)',
+        requires: 'exclusive-lock',
+        allowEnable: true,
+        allowDisable: true
+      },
+      'fast-diff': {
+        desc: 'Fast diff (requires object-map)',
+        requires: 'object-map',
+        allowEnable: true,
+        allowDisable: true
+      }
+    };
+    this.createForm();
+    for (const key of Object.keys(this.features)) {
+      const listItem = this.features[key];
+      listItem.key = key;
+      this.featuresList.push(listItem);
+    }
+  }
+
+  createForm() {
+    this.defaultFeaturesFormControl = new FormControl(true);
+    this.deepFlattenFormControl = new FormControl(false);
+    this.layeringFormControl = new FormControl(false);
+    this.exclusiveLockFormControl = new FormControl(false);
+    this.objectMapFormControl = new FormControl({value: false, disabled: true});
+    this.journalingFormControl = new FormControl({value: false, disabled: true});
+    this.fastDiffFormControl = new FormControl({value: false, disabled: true});
+    this.featuresFormGroups = new FormGroup({
+      defaultFeatures: this.defaultFeaturesFormControl,
+      'deep-flatten': this.deepFlattenFormControl,
+      'layering': this.layeringFormControl,
+      'exclusive-lock': this.exclusiveLockFormControl,
+      'object-map': this.objectMapFormControl,
+      'journaling': this.journalingFormControl,
+      'fast-diff': this.fastDiffFormControl,
+    });
+    this.rbdForm = new FormGroup({
+      name: new FormControl('', {
+        validators: [
+          Validators.required
+        ]
+      }),
+      pool: new FormControl(null, {
+        validators: [
+          Validators.required
+        ]
+      }),
+      useDataPool: new FormControl(false),
+      dataPool: new FormControl(null),
+      size: new FormControl(null, {
+        updateOn: 'blur'
+      }),
+      obj_size: new FormControl(this.defaultObjectSize),
+      features: this.featuresFormGroups,
+      stripingUnit: new FormControl(null),
+      stripingCount: new FormControl(null, {
+        updateOn: 'blur'
+      })
+    }, this.validateRbdForm(this.formatter));
+  }
+
+  disableForEdit() {
+    this.rbdForm.get('pool').disable();
+    this.rbdForm.get('useDataPool').disable();
+    this.rbdForm.get('dataPool').disable();
+    this.rbdForm.get('obj_size').disable();
+    this.rbdForm.get('stripingUnit').disable();
+    this.rbdForm.get('stripingCount').disable();
+  }
+
+  ngOnInit() {
+    if (this.router.url.startsWith('/rbd/edit')) {
+      this.editing = true;
+    }
+    if (this.editing) {
+      this.disableForEdit();
+      this.routeParamsSubscribe = this.route.params.subscribe(
+        (params: { pool: string, name: string }) => {
+          const poolName = params.pool;
+          const rbdName = params.name;
+          this.rbdService.get(poolName, rbdName)
+            .subscribe((resp: RbdFormResponseModel) => {
+              this.setResponse(resp);
+            });
+        }
+      );
+    }
+    this.poolService.list(['pool_name', 'type', 'flags_names', 'application_metadata']).then(
+      resp => {
+        const pools = [];
+        const dataPools = [];
+        for (const pool of resp) {
+          if (!_.isUndefined(pool.application_metadata.rbd)) {
+            if (pool.type === 'replicated') {
+              pools.push(pool);
+              dataPools.push(pool);
+            } else if (pool.type === 'erasure' &&
+              pool.flags_names.indexOf('ec_overwrites') !== -1) {
+              dataPools.push(pool);
+            }
+          }
+        }
+        this.pools = pools;
+        this.allPools = pools;
+        this.dataPools = dataPools;
+        this.allDataPools = dataPools;
+        if (this.pools.length === 1) {
+          const poolName = this.pools[0]['pool_name'];
+          this.rbdForm.get('pool').setValue(poolName);
+          this.onPoolChange(poolName);
+        }
+      }
+    );
+    this.defaultFeaturesFormControl.valueChanges.subscribe((value) => {
+      this.watchDataFeatures(null, value);
+    });
+    this.deepFlattenFormControl.valueChanges.subscribe((value) => {
+      this.watchDataFeatures('deep-flatten', value);
+    });
+    this.layeringFormControl.valueChanges.subscribe((value) => {
+      this.watchDataFeatures('layering', value);
+    });
+    this.exclusiveLockFormControl.valueChanges.subscribe((value) => {
+      this.watchDataFeatures('exclusive-lock', value);
+    });
+    this.objectMapFormControl.valueChanges.subscribe((value) => {
+      this.watchDataFeatures('object-map', value);
+    });
+    this.journalingFormControl.valueChanges.subscribe((value) => {
+      this.watchDataFeatures('journaling', value);
+    });
+    this.fastDiffFormControl.valueChanges.subscribe((value) => {
+      this.watchDataFeatures('fast-diff', value);
+    });
+  }
+
+  onPoolChange(selectedPoolName) {
+    const newDataPools = this.allDataPools.filter((dataPool: any) => {
+      return dataPool.pool_name !== selectedPoolName;
+    });
+    if (this.rbdForm.get('dataPool').value === selectedPoolName) {
+      this.rbdForm.get('dataPool').setValue(null);
+    }
+    this.dataPools = newDataPools;
+  }
+
+  onUseDataPoolChange () {
+    if (!this.rbdForm.get('useDataPool').value) {
+      this.rbdForm.get('dataPool').setValue(null);
+      this.onDataPoolChange(null);
+    }
+  }
+
+  onDataPoolChange(selectedDataPoolName) {
+    const newPools = this.allPools.filter((pool: any) => {
+      return pool.pool_name !== selectedDataPoolName;
+    });
+    if (this.rbdForm.get('pool').value === selectedDataPoolName) {
+      this.rbdForm.get('pool').setValue(null);
+    }
+    this.pools = newPools;
+  }
+
+  validateRbdForm(formatter: FormatterService) {
+    return (formGroup: FormGroup) => {
+      // Data Pool
+      const useDataPoolControl = formGroup.get('useDataPool');
+      const dataPoolControl = formGroup.get('dataPool');
+      let dataPoolControlErrors = null;
+      if (useDataPoolControl.value && dataPoolControl.value == null) {
+        dataPoolControlErrors = {'required': true};
+      }
+      dataPoolControl.setErrors(dataPoolControlErrors);
+      // Size
+      const sizeControl = formGroup.get('size');
+      const objectSizeControl = formGroup.get('obj_size');
+      const objectSizeInBytes = formatter.toBytes(
+        objectSizeControl.value != null ? objectSizeControl.value : this.defaultObjectSize);
+      const stripingCountControl = formGroup.get('stripingCount');
+      const stripingCount = stripingCountControl.value != null ? stripingCountControl.value : 1;
+      let sizeControlErrors = null;
+      if (sizeControl.value === null) {
+        sizeControlErrors = {'required': true};
+      } else {
+        const sizeInBytes = formatter.toBytes(sizeControl.value);
+        if (stripingCount * objectSizeInBytes > sizeInBytes) {
+          sizeControlErrors = {'invalidSizeObject': true};
+        }
+      }
+      sizeControl.setErrors(sizeControlErrors);
+      // Striping Unit
+      const stripingUnitControl = formGroup.get('stripingUnit');
+      let stripingUnitControlErrors = null;
+      if (stripingUnitControl.value === null && stripingCountControl.value !== null) {
+        stripingUnitControlErrors = {'required': true};
+      } else if (stripingUnitControl.value !== null) {
+        const stripingUnitInBytes = formatter.toBytes(stripingUnitControl.value);
+        if (stripingUnitInBytes > objectSizeInBytes) {
+          stripingUnitControlErrors = {'invalidStripingUnit': true};
+        }
+      }
+      stripingUnitControl.setErrors(stripingUnitControlErrors);
+      // Striping Count
+      let stripingCountControlErrors = null;
+      if (stripingCountControl.value === null && stripingUnitControl.value !== null) {
+        stripingCountControlErrors = {'required': true};
+      } else if (stripingCount < 1) {
+        stripingCountControlErrors = {'min': true};
+      }
+      stripingCountControl.setErrors(stripingCountControlErrors);
+    };
+  }
+
+  deepBoxCheck(key, checked) {
+    _.forIn(this.features, (details, feature) => {
+      if (details.requires === key) {
+        if (checked) {
+          this.featuresFormGroups.get(feature).enable();
+        } else {
+          this.featuresFormGroups.get(feature).disable();
+          this.featuresFormGroups.get(feature).setValue(checked);
+          this.watchDataFeatures(feature, checked);
+          this.deepBoxCheck(feature, checked);
+        }
+      }
+      if (this.editing && this.featuresFormGroups.get(feature).enabled) {
+
+        if (this.response.features_name.indexOf(feature) !== -1 && !details.allowDisable) {
+          this.featuresFormGroups.get(feature).disable();
+
+        } else if (this.response.features_name.indexOf(feature) === -1 && !details.allowEnable) {
+          this.featuresFormGroups.get(feature).disable();
+        }
+      }
+    });
+  }
+
+  featureFormUpdate(key, checked) {
+    if (checked) {
+      const required = this.features[key].requires;
+      if (required && !this.featuresFormGroups.get(required).value) {
+        this.featuresFormGroups.get(key).setValue(false);
+        return;
+      }
+    }
+    this.deepBoxCheck(key, checked);
+  }
+
+  watchDataFeatures(key, checked) {
+    if (!this.defaultFeaturesFormControl.value && key) {
+      this.featureFormUpdate(key, checked);
+    }
+  }
+
+  setResponse(response: RbdFormResponseModel) {
+    this.response = response;
+    this.rbdForm.get('name').setValue(response.name);
+    this.rbdForm.get('pool').setValue(response.pool_name);
+    if (response.data_pool) {
+      this.rbdForm.get('useDataPool').setValue(true);
+      this.rbdForm.get('dataPool').setValue(response.data_pool);
+    }
+    this.rbdForm.get('size').setValue(this.dimlessBinaryPipe.transform(response.size));
+    this.rbdForm.get('obj_size').setValue(this.dimlessBinaryPipe.transform(response.obj_size));
+    const featuresControl = this.rbdForm.get('features');
+    featuresControl.get('defaultFeatures').setValue(false);
+    _.forIn(this.features, (feature) => {
+      if (response.features_name.indexOf(feature.key) !== -1) {
+        featuresControl.get(feature.key).setValue(true);
+      }
+    });
+    this.rbdForm.get('stripingUnit').setValue(
+      this.dimlessBinaryPipe.transform(response.stripe_unit));
+    this.rbdForm.get('stripingCount').setValue(response.stripe_count);
+  }
+
+  createRequest() {
+    const request = new RbdFormCreateRequestModel();
+    request.pool_name = this.rbdForm.get('pool').value;
+    request.name = this.rbdForm.get('name').value;
+    request.size = this.formatter.toBytes(this.rbdForm.get('size').value);
+    request.obj_size = this.formatter.toBytes(this.rbdForm.get('obj_size').value);
+    if (!this.defaultFeaturesFormControl.value) {
+      _.forIn(this.features, (feature) => {
+        if (this.featuresFormGroups.get(feature.key).value) {
+          request.features.push(feature.key);
+        }
+      });
+    } else {
+      request.features = null;
+    }
+    request.stripe_unit = this.formatter.toBytes(this.rbdForm.get('stripingUnit').value);
+    request.stripe_count = this.rbdForm.get('stripingCount').value;
+    request.data_pool = this.rbdForm.get('dataPool').value;
+    return request;
+  }
+
+  createAction() {
+    const request = this.createRequest();
+    const finishedTask = new FinishedTask();
+    finishedTask.name = 'rbd/create';
+    finishedTask.metadata = {'pool_name': request.pool_name, 'image_name': request.name};
+    this.rbdService.create(request).toPromise().then((resp) => {
+      if (resp.status === 202) {
+        this.notificationService.show(NotificationType.info,
+          `RBD creation in progress...`,
+          this.taskManagerMessageService.getDescription(finishedTask));
+        this.taskManagerService.subscribe(finishedTask.name, finishedTask.metadata,
+          (asyncFinishedTask: FinishedTask) => {
+            this.notificationService.notifyTask(asyncFinishedTask);
+          });
+      } else {
+        finishedTask.success = true;
+        this.notificationService.notifyTask(finishedTask);
+      }
+      this.router.navigate(['/block/rbd']);
+    }, (resp) => {
+      this.rbdForm.setErrors({'cdSubmitButton': true});
+      finishedTask.success = false;
+      finishedTask.exception = resp.error;
+      this.notificationService.notifyTask(finishedTask);
+    });
+  }
+
+  editRequest() {
+    const request = new RbdFormEditRequestModel();
+    request.name = this.rbdForm.get('name').value;
+    request.size = this.formatter.toBytes(this.rbdForm.get('size').value);
+    if (!this.defaultFeaturesFormControl.value) {
+      _.forIn(this.features, (feature) => {
+        if (this.featuresFormGroups.get(feature.key).value) {
+          request.features.push(feature.key);
+        }
+      });
+    }
+    return request;
+  }
+
+  editAction() {
+    const request = this.editRequest();
+    const finishedTask = new FinishedTask();
+    finishedTask.name = 'rbd/edit';
+    finishedTask.metadata = {
+      'pool_name':  this.response.pool_name,
+      'image_name': this.response.name
+    };
+    this.rbdService.update(this.response.pool_name, this.response.name, request)
+      .toPromise().then((resp) => {
+        if (resp.status === 202) {
+          this.notificationService.show(NotificationType.info,
+            `RBD update in progress...`,
+            this.taskManagerMessageService.getDescription(finishedTask));
+          this.taskManagerService.subscribe(finishedTask.name, finishedTask.metadata,
+            (asyncFinishedTask: FinishedTask) => {
+              this.notificationService.notifyTask(asyncFinishedTask);
+            });
+        } else {
+          finishedTask.success = true;
+          this.notificationService.notifyTask(finishedTask);
+        }
+        this.router.navigate(['/block/rbd']);
+      }).catch((resp) => {
+        this.rbdForm.setErrors({'cdSubmitButton': true});
+        finishedTask.success = false;
+        finishedTask.exception = resp.error;
+        this.notificationService.notifyTask(finishedTask);
+      });
+  }
+
+  submit() {
+    if (this.editing) {
+      this.editAction();
+    } else {
+      this.createAction();
+    }
+  }
+
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.model.ts
new file mode 100644 (file)
index 0000000..014b827
--- /dev/null
@@ -0,0 +1,9 @@
+export class RbdFormModel {
+  name: string;
+  pool_name: string;
+  data_pool: string;
+  size: number;
+  obj_size: number;
+  stripe_unit: number;
+  stripe_count: number;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.html
new file mode 100644 (file)
index 0000000..c568ae0
--- /dev/null
@@ -0,0 +1,68 @@
+<nav aria-label="breadcrumb">
+  <ol class="breadcrumb">
+    <li i18n
+        class="breadcrumb-item">Block</li>
+    <li i18n
+        class="breadcrumb-item active"
+        aria-current="page">Images</li>
+  </ol>
+</nav>
+
+<cd-view-cache *ngFor="let viewCacheStatus of viewCacheStatusList"
+               [status]="viewCacheStatus.status"
+               [statusFor]="viewCacheStatus.statusFor"></cd-view-cache>
+
+<cd-table [data]="images"
+          columnMode="flex"
+          [columns]="columns"
+          identifier="name"
+          selectionType="single"
+          (updateSelection)="updateSelection($event)">
+  <div class="table-actions">
+    <div class="btn-group" dropdown>
+      <button type="button"
+              class="btn btn-sm btn-primary"
+              *ngIf="!selection.hasSingleSelection"
+              routerLink="/rbd/add">
+        <i class="fa fa-fw fa-plus"></i><span i18n>Add</span>
+      </button>
+      <button type="button"
+              class="btn btn-sm btn-primary"
+              *ngIf="selection.hasSingleSelection"
+              [ngClass]="{'disabled': selection.first().executing}"
+              routerLink="/rbd/edit/{{ selection.first()?.pool_name }}/{{ selection.first()?.name }}">
+        <i class="fa fa-fw fa-pencil"></i><span i18n>Edit</span>
+      </button>
+      <button type="button" dropdownToggle class="btn btn-sm btn-primary dropdown-toggle dropdown-toggle-split">
+        <span class="caret"></span>
+        <span class="sr-only"></span>
+      </button>
+      <ul *dropdownMenu class="dropdown-menu" role="menu">
+        <li role="menuitem">
+          <a class="dropdown-item" routerLink="/rbd/add"><i class="fa fa-fw fa-plus"></i><span i18n>Add</span></a>
+        </li>
+        <li role="menuitem"
+            [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first().executing}">
+          <a class="dropdown-item" routerLink="/rbd/edit/{{ selection.first()?.pool_name }}/{{ selection.first()?.name }}"><i class="fa fa-fw fa-pencil"></i><span i18n>Edit</span></a>
+        </li>
+        <li role="menuitem"
+            [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first().executing}">
+          <a class="dropdown-item" (click)="deleteRbdModal()"><i class="fa fa-fw fa-trash-o"></i><span i18n>Delete</span></a>
+        </li>
+      </ul>
+    </div>
+  </div>
+  <cd-rbd-details cdTableDetail
+                  [selection]="selection">
+  </cd-rbd-details>
+</cd-table>
+
+<ng-template #usageNotAvailableTooltipTpl>
+  <div i18n [innerHtml]="'Only available for RBD images with <strong>fast-diff</strong> enabled'"></div>
+</ng-template>
+
+<ng-template #parentTpl
+             let-value="value">
+  <span *ngIf="value">{{ value.pool_name }}/{{ value.image_name }}@{{ value.snap_name }}</span>
+  <span *ngIf="!value">-</span>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.spec.ts
new file mode 100644 (file)
index 0000000..fa6c5bd
--- /dev/null
@@ -0,0 +1,52 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import {
+  AlertModule,
+  BsDropdownModule,
+  ModalModule,
+  TabsModule,
+  TooltipModule
+} from 'ngx-bootstrap';
+
+import { ComponentsModule } from '../../../shared/components/components.module';
+import { SharedModule } from '../../../shared/shared.module';
+import { RbdDetailsComponent } from '../rbd-details/rbd-details.component';
+import { RbdSnapshotListComponent } from '../rbd-snapshot-list/rbd-snapshot-list.component';
+import { RbdListComponent } from './rbd-list.component';
+
+describe('RbdListComponent', () => {
+  let component: RbdListComponent;
+  let fixture: ComponentFixture<RbdListComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      imports: [
+        SharedModule,
+        BsDropdownModule.forRoot(),
+        TabsModule.forRoot(),
+        ModalModule.forRoot(),
+        TooltipModule.forRoot(),
+        ToastModule.forRoot(),
+        AlertModule.forRoot(),
+        ComponentsModule,
+        RouterTestingModule,
+        HttpClientTestingModule
+      ],
+      declarations: [ RbdListComponent, RbdDetailsComponent, RbdSnapshotListComponent ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RbdListComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-list.component.ts
new file mode 100644 (file)
index 0000000..f90014e
--- /dev/null
@@ -0,0 +1,253 @@
+import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import * as _ from 'lodash';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap';
+
+import { RbdService } from '../../../shared/api/rbd.service';
+import {
+  DeleteConfirmationComponent
+} from '../../../shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { CellTemplate } from '../../../shared/enum/cell-template.enum';
+import { NotificationType } from '../../../shared/enum/notification-type.enum';
+import { ViewCacheStatus } from '../../../shared/enum/view-cache-status.enum';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { ExecutingTask } from '../../../shared/models/executing-task';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '../../../shared/pipes/dimless.pipe';
+import {
+  NotificationService
+} from '../../../shared/services/notification.service';
+import { SummaryService } from '../../../shared/services/summary.service';
+import { TaskManagerMessageService } from '../../../shared/services/task-manager-message.service';
+import { TaskManagerService } from '../../../shared/services/task-manager.service';
+import { RbdModel } from './rbd-model';
+
+@Component({
+  selector: 'cd-rbd-list',
+  templateUrl: './rbd-list.component.html',
+  styleUrls: ['./rbd-list.component.scss']
+})
+export class RbdListComponent implements OnInit, OnDestroy {
+
+  @ViewChild('usageTpl') usageTpl: TemplateRef<any>;
+  @ViewChild('parentTpl') parentTpl: TemplateRef<any>;
+  @ViewChild('nameTpl') nameTpl: TemplateRef<any>;
+
+  images: any;
+  executingTasks: ExecutingTask[] = [];
+  columns: CdTableColumn[];
+  retries: number;
+  viewCacheStatusList: any[];
+  selection = new CdTableSelection();
+
+  summaryDataSubscription = null;
+
+  modalRef: BsModalRef;
+
+  constructor(private rbdService: RbdService,
+              private dimlessBinaryPipe: DimlessBinaryPipe,
+              private dimlessPipe: DimlessPipe,
+              private summaryService: SummaryService,
+              private modalService: BsModalService,
+              private notificationService: NotificationService,
+              private taskManagerMessageService: TaskManagerMessageService,
+              private taskManagerService: TaskManagerService) {
+  }
+
+  ngOnInit() {
+    this.columns = [
+      {
+        name: 'Name',
+        prop: 'name',
+        flexGrow: 2,
+        cellTransformation: CellTemplate.executing
+      },
+      {
+        name: 'Pool',
+        prop: 'pool_name',
+        flexGrow: 2
+      },
+      {
+        name: 'Size',
+        prop: 'size',
+        flexGrow: 1,
+        cellClass: 'text-right',
+        pipe: this.dimlessBinaryPipe
+      },
+      {
+        name: 'Objects',
+        prop: 'num_objs',
+        flexGrow: 1,
+        cellClass: 'text-right',
+        pipe: this.dimlessPipe
+      },
+      {
+        name: 'Object size',
+        prop: 'obj_size',
+        flexGrow: 1,
+        cellClass: 'text-right',
+        pipe: this.dimlessBinaryPipe
+      },
+      {
+        name: 'Provisioned',
+        prop: 'disk_usage',
+        cellClass: 'text-center',
+        flexGrow: 1,
+        pipe: this.dimlessBinaryPipe
+      },
+      {
+        name: 'Total provisioned',
+        prop: 'total_disk_usage',
+        cellClass: 'text-center',
+        flexGrow: 1,
+        pipe: this.dimlessBinaryPipe
+      },
+      {
+        name: 'Parent',
+        prop: 'parent',
+        flexGrow: 2,
+        cellTemplate: this.parentTpl
+      }
+    ];
+
+    this.summaryService.get().then(resp => {
+      this.loadImages(resp.executing_tasks);
+      this.summaryDataSubscription = this.summaryService.summaryData$.subscribe((data: any) => {
+        this.loadImages(data.executing_tasks);
+      });
+    });
+
+  }
+
+  ngOnDestroy() {
+    if (this.summaryDataSubscription) {
+      this.summaryDataSubscription.unsubscribe();
+    }
+  }
+
+  loadImages(executingTasks) {
+    if (executingTasks === null) {
+      executingTasks = this.executingTasks;
+    }
+    this.rbdService.list()
+      .subscribe(
+      (resp: any[]) => {
+        let images = [];
+        const viewCacheStatusMap = {};
+        resp.forEach(pool => {
+          if (_.isUndefined(viewCacheStatusMap[pool.status])) {
+            viewCacheStatusMap[pool.status] = [];
+          }
+          viewCacheStatusMap[pool.status].push(pool.pool_name);
+          images = images.concat(pool.value);
+        });
+        const viewCacheStatusList = [];
+        _.forEach(viewCacheStatusMap, (value, key) => {
+          viewCacheStatusList.push({
+            status: parseInt(key, 10),
+            statusFor: (value.length > 1 ? 'pools ' : 'pool ') +
+            '<strong>' + value.join('</strong>, <strong>') + '</strong>'
+          });
+        });
+        this.viewCacheStatusList = viewCacheStatusList;
+        images.forEach(image => {
+          image.executingTasks = this._getExecutingTasks(executingTasks,
+            image.pool_name, image.name);
+        });
+        this.images = this.merge(images, executingTasks);
+        this.executingTasks = executingTasks;
+      },
+      () => {
+        this.viewCacheStatusList = [{status: ViewCacheStatus.ValueException}];
+      }
+    );
+  }
+
+  _getExecutingTasks(executingTasks: ExecutingTask[], poolName, imageName): ExecutingTask[] {
+    const result: ExecutingTask[] = [];
+    executingTasks.forEach(executingTask => {
+      if (executingTask.name === 'rbd/snap/create' ||
+        executingTask.name === 'rbd/snap/delete' ||
+        executingTask.name === 'rbd/snap/edit' ||
+        executingTask.name === 'rbd/snap/rollback') {
+        if (poolName === executingTask.metadata['pool_name'] &&
+          imageName === executingTask.metadata['image_name']) {
+          result.push(executingTask);
+        }
+      }
+    });
+    return result;
+  }
+
+  private merge(rbds: RbdModel[], executingTasks: ExecutingTask[] = []) {
+    const resultRBDs = _.clone(rbds);
+    executingTasks.forEach((executingTask) => {
+      const rbdExecuting = resultRBDs.find((rbd) => {
+        return rbd.pool_name === executingTask.metadata['pool_name'] &&
+          rbd.name === executingTask.metadata['image_name'];
+      });
+      if (rbdExecuting) {
+        if (executingTask.name === 'rbd/delete') {
+          rbdExecuting.cdExecuting = 'deleting';
+
+        } else if (executingTask.name === 'rbd/edit') {
+          rbdExecuting.cdExecuting = 'updating';
+        }
+      } else if (executingTask.name === 'rbd/create') {
+        const rbdModel = new RbdModel();
+        rbdModel.name = executingTask.metadata['image_name'];
+        rbdModel.pool_name = executingTask.metadata['pool_name'];
+        rbdModel.cdExecuting = 'creating';
+        resultRBDs.push(rbdModel);
+      }
+    });
+    return resultRBDs;
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  deleteRbd(poolName: string, imageName: string) {
+    const finishedTask = new FinishedTask();
+    finishedTask.name = 'rbd/delete';
+    finishedTask.metadata = {'pool_name': poolName, 'image_name': imageName};
+    this.rbdService.delete(poolName, imageName)
+      .toPromise().then((resp) => {
+        if (resp.status === 202) {
+          this.notificationService.show(NotificationType.info,
+            `RBD deletion in progress...`,
+            this.taskManagerMessageService.getDescription(finishedTask));
+          const executingTask = new ExecutingTask();
+          executingTask.name = finishedTask.name;
+          executingTask.metadata = finishedTask.metadata;
+          this.executingTasks.push(executingTask);
+          this.taskManagerService.subscribe(executingTask.name, executingTask.metadata,
+            (asyncFinishedTask: FinishedTask) => {
+              this.notificationService.notifyTask(asyncFinishedTask);
+            });
+        } else {
+          finishedTask.success = true;
+          this.notificationService.notifyTask(finishedTask);
+        }
+        this.modalRef.hide();
+        this.loadImages(null);
+      }).catch((resp) => {
+        finishedTask.success = false;
+        finishedTask.exception = resp.error;
+        this.notificationService.notifyTask(finishedTask);
+      });
+  }
+
+  deleteRbdModal() {
+    const poolName = this.selection.first().pool_name;
+    const imageName = this.selection.first().name;
+    this.modalRef = this.modalService.show(DeleteConfirmationComponent);
+    this.modalRef.content.itemName = `${poolName}/${imageName}`;
+    this.modalRef.content.onSubmit.subscribe(() => {
+      this.deleteRbd(poolName, imageName);
+    });
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-list/rbd-model.ts
new file mode 100644 (file)
index 0000000..8be8daf
--- /dev/null
@@ -0,0 +1,6 @@
+export class RbdModel {
+  name: string;
+  pool_name: string;
+
+  cdExecuting: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.html
new file mode 100644 (file)
index 0000000..dc27702
--- /dev/null
@@ -0,0 +1,50 @@
+<div class="modal-header">
+  <h4 class="modal-title pull-left">{{ editing ? 'Rename' : 'Create' }} RBD Snapshot</h4>
+  <button type="button" class="close pull-right" aria-label="Close" (click)="modalRef.hide()">
+    <span aria-hidden="true">&times;</span>
+  </button>
+</div>
+<form name="snapshotForm"
+      class="form-horizontal"
+      #formDir="ngForm"
+      [formGroup]="snapshotForm"
+      novalidate>
+  <div class="modal-body">
+
+    <!-- Name -->
+    <div class="form-group"
+         [ngClass]="{'has-error': (formDir.submitted || snapshotForm.controls.snapshotName.dirty) && snapshotForm.controls.snapshotName.invalid}">
+      <label i18n
+             class="control-label col-sm-3"
+             for="snapshotName">Name
+        <span class="required"></span>
+      </label>
+      <div class="col-sm-9">
+        <input class="form-control"
+               type="text"
+               placeholder="Snapshot name..."
+               id="snapshotName"
+               name="snapshotName"
+               formControlName="snapshotName"
+               autofocus>
+        <span i18n
+              class="help-block"
+              *ngIf="(formDir.submitted || snapshotForm.controls.snapshotName.dirty) && snapshotForm.controls.snapshotName.hasError('required')">
+              This field is required.
+            </span>
+      </div>
+    </div>
+
+  </div>
+
+  <div class="modal-footer">
+    <div class="button-group text-right">
+      <cd-submit-button [form]="snapshotForm"
+                        (submitAction)="submit()">
+        {{ editing ? 'Rename' : 'Create' }} Snapshot
+      </cd-submit-button>
+      <button type="button" class="btn btn-sm btn-default" (click)="modalRef.hide()">Close</button>
+    </div>
+  </div>
+</form>
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.spec.ts
new file mode 100644 (file)
index 0000000..3217fe3
--- /dev/null
@@ -0,0 +1,43 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { ToastModule } from 'ng2-toastr';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap';
+
+import { ApiModule } from '../../../shared/api/api.module';
+import { ComponentsModule } from '../../../shared/components/components.module';
+import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { ServicesModule } from '../../../shared/services/services.module';
+import { RbdSnapshotFormComponent } from './rbd-snapshot-form.component';
+
+describe('RbdSnapshotFormComponent', () => {
+  let component: RbdSnapshotFormComponent;
+  let fixture: ComponentFixture<RbdSnapshotFormComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      imports: [
+        ReactiveFormsModule,
+        ComponentsModule,
+        HttpClientTestingModule,
+        ServicesModule,
+        ApiModule,
+        ToastModule.forRoot()
+      ],
+      declarations: [ RbdSnapshotFormComponent ],
+      providers: [ BsModalRef, BsModalService, AuthStorageService ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RbdSnapshotFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.ts
new file mode 100644 (file)
index 0000000..42d19b3
--- /dev/null
@@ -0,0 +1,115 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, FormGroup, Validators } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap';
+import { Subject } from 'rxjs/Subject';
+
+import { RbdService } from '../../../shared/api/rbd.service';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import {
+  NotificationService
+} from '../../../shared/services/notification.service';
+import { TaskManagerService } from '../../../shared/services/task-manager.service';
+
+@Component({
+  selector: 'cd-rbd-snapshot-form',
+  templateUrl: './rbd-snapshot-form.component.html',
+  styleUrls: ['./rbd-snapshot-form.component.scss']
+})
+export class RbdSnapshotFormComponent implements OnInit {
+
+  poolName: string;
+  imageName: string;
+  snapName: string;
+
+  snapshotForm: FormGroup;
+
+  editing = false;
+
+  public onSubmit: Subject<string>;
+
+  constructor(public modalRef: BsModalRef,
+              private rbdService: RbdService,
+              private taskManagerService: TaskManagerService,
+              private notificationService: NotificationService) {
+    this.createForm();
+  }
+
+  createForm() {
+    this.snapshotForm = new FormGroup({
+      snapshotName: new FormControl('', {
+        validators: [
+          Validators.required
+        ]
+      })
+    });
+  }
+
+  ngOnInit() {
+    this.onSubmit = new Subject();
+  }
+
+  setSnapName(snapName) {
+    this.snapName = snapName;
+    this.snapshotForm.get('snapshotName').setValue(snapName);
+    this.editing = true;
+  }
+
+  editAction() {
+    const snapshotName = this.snapshotForm.get('snapshotName').value;
+    const finishedTask = new FinishedTask();
+    finishedTask.name = 'rbd/snap/edit';
+    finishedTask.metadata = {
+      'pool_name': this.poolName,
+      'image_name': this.imageName,
+      'snapshot_name': snapshotName
+    };
+    this.rbdService.renameSnapshot(this.poolName, this.imageName, this.snapName, snapshotName)
+      .toPromise().then((resp) => {
+        this.taskManagerService.subscribe(finishedTask.name, finishedTask.metadata,
+          (asyncFinishedTask: FinishedTask) => {
+            this.notificationService.notifyTask(asyncFinishedTask);
+          });
+        this.modalRef.hide();
+        this.onSubmit.next(this.snapName);
+      }).catch((resp) => {
+        this.snapshotForm.setErrors({'cdSubmitButton': true});
+        finishedTask.success = false;
+        finishedTask.exception = resp.error;
+        this.notificationService.notifyTask(finishedTask);
+      });
+  }
+
+  createAction() {
+    const snapshotName = this.snapshotForm.get('snapshotName').value;
+    const finishedTask = new FinishedTask();
+    finishedTask.name = 'rbd/snap/create';
+    finishedTask.metadata = {
+      'pool_name': this.poolName,
+      'image_name': this.imageName,
+      'snapshot_name': snapshotName
+    };
+    this.rbdService.createSnapshot(this.poolName, this.imageName, snapshotName)
+      .toPromise().then((resp) => {
+        this.taskManagerService.subscribe(finishedTask.name, finishedTask.metadata,
+          (asyncFinishedTask: FinishedTask) => {
+            this.notificationService.notifyTask(asyncFinishedTask);
+          });
+        this.modalRef.hide();
+        this.onSubmit.next(snapshotName);
+      }).catch((resp) => {
+        this.snapshotForm.setErrors({'cdSubmitButton': true});
+        finishedTask.success = false;
+        finishedTask.exception = resp.error;
+        this.notificationService.notifyTask(finishedTask);
+      });
+  }
+
+  submit() {
+    if (this.editing) {
+      this.editAction();
+    } else {
+      this.createAction();
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html
new file mode 100644 (file)
index 0000000..76a1d42
--- /dev/null
@@ -0,0 +1,53 @@
+<cd-table [data]="data"
+          columnMode="flex"
+          selectionType="single"
+          (updateSelection)="updateSelection($event)"
+          [columns]="columns">
+  <div class="table-actions">
+    <div class="btn-group" dropdown>
+      <button type="button"
+              class="btn btn-sm btn-primary"
+              *ngIf="!selection.hasSingleSelection"
+              (click)="openCreateSnapshotModal()">
+        <i class="fa fa-fw fa-plus"></i><span i18n>Create</span>
+      </button>
+      <button type="button"
+              class="btn btn-sm btn-primary"
+              *ngIf="selection.hasSingleSelection"
+              [ngClass]="{'disabled': selection.first().executing}"
+              (click)="openEditSnapshotModal()">
+        <i class="fa fa-fw fa-pencil"></i><span i18n>Rename</span>
+      </button>
+      <button type="button" dropdownToggle class="btn btn-sm btn-primary dropdown-toggle dropdown-toggle-split">
+        <span class="caret"></span>
+        <span class="sr-only"></span>
+      </button>
+      <ul *dropdownMenu class="dropdown-menu" role="menu">
+        <li role="menuitem"><a class="dropdown-item" (click)="openCreateSnapshotModal()"><i class="fa fa-fw fa-plus"></i><span i18n>Create</span></a></li>
+        <li role="menuitem"
+            [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first().executing}"><a class="dropdown-item" (click)="openEditSnapshotModal()"><i class="fa fa-fw fa-pencil"></i><span i18n>Rename</span></a></li>
+        <li role="menuitem"
+            [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first().executing}">
+          <a class="dropdown-item" (click)="toggleProtection()">
+            <span *ngIf="!selection.first()?.is_protected"><i class="fa fa-fw fa-lock"></i><span i18n>Protect</span></span>
+            <span *ngIf="selection.first()?.is_protected"><i class="fa fa-fw fa-unlock"></i><span i18n>Unprotect</span></span>
+          </a>
+        </li>
+        <li role="menuitem"
+            [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first().executing}">
+          <a class="dropdown-item" (click)="rollbackModal()"><i class="fa fa-fw fa-undo"></i><span i18n>Rollback</span></a>
+        </li>
+        <li role="menuitem"
+            [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first().executing || selection.first().is_protected}">
+          <a class="dropdown-item" (click)="deleteSnapshotModal()"><i class="fa fa-fw fa-trash-o"></i><span i18n>Delete</span></a>
+        </li>
+      </ul>
+    </div>
+  </div>
+</cd-table>
+
+<ng-template #protectTpl
+             let-value="value">
+  <span *ngIf="value" class="label label-success">PROTECTED</span>
+  <span *ngIf="!value" class="label label-info">UNPROTECTED</span>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.spec.ts
new file mode 100644 (file)
index 0000000..e4ce344
--- /dev/null
@@ -0,0 +1,44 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import { ModalModule } from 'ngx-bootstrap';
+
+import { ApiModule } from '../../../shared/api/api.module';
+import { ComponentsModule } from '../../../shared/components/components.module';
+import { DataTableModule } from '../../../shared/datatable/datatable.module';
+import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { ServicesModule } from '../../../shared/services/services.module';
+import { RbdSnapshotListComponent } from './rbd-snapshot-list.component';
+
+describe('RbdSnapshotListComponent', () => {
+  let component: RbdSnapshotListComponent;
+  let fixture: ComponentFixture<RbdSnapshotListComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ RbdSnapshotListComponent ],
+      imports: [
+        DataTableModule,
+        ComponentsModule,
+        ModalModule.forRoot(),
+        ToastModule.forRoot(),
+        ServicesModule,
+        ApiModule,
+        HttpClientTestingModule
+      ],
+      providers: [ AuthStorageService ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RbdSnapshotListComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.ts
new file mode 100644 (file)
index 0000000..9730579
--- /dev/null
@@ -0,0 +1,238 @@
+import {
+  Component,
+  Input,
+  OnChanges,
+  OnInit,
+  TemplateRef,
+  ViewChild
+} from '@angular/core';
+
+import * as _ from 'lodash';
+import { ToastsManager } from 'ng2-toastr';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap';
+
+import {
+  RbdService
+} from '../../../shared/api/rbd.service';
+import {
+  DeleteConfirmationComponent
+} from '../../../shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { CellTemplate } from '../../../shared/enum/cell-template.enum';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { ExecutingTask } from '../../../shared/models/executing-task';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { CdDatePipe } from '../../../shared/pipes/cd-date.pipe';
+import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import {
+  NotificationService
+} from '../../../shared/services/notification.service';
+import { TaskManagerService } from '../../../shared/services/task-manager.service';
+import { RbdSnapshotFormComponent } from '../rbd-snapshot-form/rbd-snapshot-form.component';
+import {
+  RollbackConfirmationModalComponent
+} from '../rollback-confirmation-modal/rollback-confimation-modal.component';
+import { RbdSnapshotModel } from './rbd-snapshot.model';
+
+@Component({
+  selector: 'cd-rbd-snapshot-list',
+  templateUrl: './rbd-snapshot-list.component.html',
+  styleUrls: ['./rbd-snapshot-list.component.scss']
+})
+export class RbdSnapshotListComponent implements OnInit, OnChanges {
+
+  @Input() snapshots: RbdSnapshotModel[] = [];
+  @Input() poolName: string;
+  @Input() rbdName: string;
+  @Input() executingTasks: ExecutingTask[] = [];
+
+  @ViewChild('nameTpl') nameTpl: TemplateRef<any>;
+  @ViewChild('protectTpl') protectTpl: TemplateRef<any>;
+
+  data: RbdSnapshotModel[];
+
+  columns: CdTableColumn[];
+
+  modalRef: BsModalRef;
+
+  selection = new CdTableSelection();
+
+  constructor(private modalService: BsModalService,
+              private dimlessBinaryPipe: DimlessBinaryPipe,
+              private cdDatePipe: CdDatePipe,
+              private rbdService: RbdService,
+              private toastr: ToastsManager,
+              private taskManagerService: TaskManagerService,
+              private notificationService: NotificationService) { }
+
+  ngOnInit() {
+    this.columns = [
+      {
+        name: 'Name',
+        prop: 'name',
+        cellTransformation: CellTemplate.executing,
+        flexGrow: 2
+      },
+      {
+        name: 'Size',
+        prop: 'size',
+        flexGrow: 1,
+        cellClass: 'text-right',
+        pipe: this.dimlessBinaryPipe
+      },
+      {
+        name: 'Provisioned',
+        prop: 'disk_usage',
+        flexGrow: 1,
+        cellClass: 'text-right',
+        pipe: this.dimlessBinaryPipe
+      },
+      {
+        name: 'State',
+        prop: 'is_protected',
+        flexGrow: 1,
+        cellClass: 'text-center',
+        cellTemplate: this.protectTpl
+      },
+      {
+        name: 'Created',
+        prop: 'timestamp',
+        flexGrow: 1,
+        pipe: this.cdDatePipe
+      }
+    ];
+  }
+
+  ngOnChanges() {
+    this.data = this.merge(this.snapshots, this.executingTasks);
+  }
+
+  private merge(snapshots: RbdSnapshotModel[], executingTasks: ExecutingTask[] = []) {
+    const resultSnapshots = _.clone(snapshots);
+    executingTasks.forEach((executingTask) => {
+      const snapshotExecuting = resultSnapshots.find((snapshot) => {
+        return snapshot.name === executingTask.metadata['snapshot_name'];
+      });
+      if (snapshotExecuting) {
+        if (executingTask.name === 'rbd/snap/delete') {
+          snapshotExecuting.cdExecuting = 'deleting';
+
+        } else if (executingTask.name === 'rbd/snap/edit') {
+          snapshotExecuting.cdExecuting = 'updating';
+
+        } else if (executingTask.name === 'rbd/snap/rollback') {
+          snapshotExecuting.cdExecuting = 'rolling back';
+        }
+      } else if (executingTask.name === 'rbd/snap/create') {
+        const rbdSnapshotModel = new RbdSnapshotModel();
+        rbdSnapshotModel.name = executingTask.metadata['snapshot_name'];
+        rbdSnapshotModel.cdExecuting = 'creating';
+        resultSnapshots.push(rbdSnapshotModel);
+      }
+    });
+    return resultSnapshots;
+  }
+
+  private openSnapshotModal(taskName: string, oldSnapshotName: string = null) {
+    this.modalRef = this.modalService.show(RbdSnapshotFormComponent);
+    this.modalRef.content.poolName = this.poolName;
+    this.modalRef.content.imageName = this.rbdName;
+    if (oldSnapshotName) {
+      this.modalRef.content.setSnapName(this.selection.first().name);
+    }
+    this.modalRef.content.onSubmit.subscribe((snapshotName: string) => {
+      const executingTask = new ExecutingTask();
+      executingTask.name = taskName;
+      executingTask.metadata = {'snapshot_name': snapshotName};
+      this.executingTasks.push(executingTask);
+      this.ngOnChanges();
+    });
+  }
+
+  openCreateSnapshotModal() {
+    this.openSnapshotModal('rbd/snap/create');
+  }
+
+  openEditSnapshotModal() {
+    this.openSnapshotModal('rbd/snap/edit', this.selection.first().name);
+  }
+
+  toggleProtection() {
+    const snapshotName = this.selection.first().name;
+    const isProtected = this.selection.first().is_protected;
+    const finishedTask = new FinishedTask();
+    finishedTask.name = 'rbd/snap/edit';
+    finishedTask.metadata = {
+      'pool_name': this.poolName,
+      'image_name': this.rbdName,
+      'snapshot_name': snapshotName
+    };
+    this.rbdService.protectSnapshot(this.poolName, this.rbdName, snapshotName, !isProtected)
+      .toPromise().then((resp) => {
+        const executingTask = new ExecutingTask();
+        executingTask.name = finishedTask.name;
+        executingTask.metadata = finishedTask.metadata;
+        this.executingTasks.push(executingTask);
+        this.ngOnChanges();
+        this.taskManagerService.subscribe(finishedTask.name, finishedTask.metadata,
+          (asyncFinishedTask: FinishedTask) => {
+            this.notificationService.notifyTask(asyncFinishedTask);
+          });
+      }).catch((resp) => {
+        finishedTask.success = false;
+        finishedTask.exception = resp.error;
+        this.notificationService.notifyTask(finishedTask);
+      });
+  }
+
+  private asyncTask(task: string, taskName: string, snapshotName: string) {
+    const finishedTask = new FinishedTask();
+    finishedTask.name = taskName;
+    finishedTask.metadata = {
+      'pool_name': this.poolName,
+      'image_name': this.rbdName,
+      'snapshot_name': snapshotName
+    };
+    this.rbdService[task](this.poolName, this.rbdName, snapshotName)
+      .toPromise().then(() => {
+        const executingTask = new ExecutingTask();
+        executingTask.name = finishedTask.name;
+        executingTask.metadata = finishedTask.metadata;
+        this.executingTasks.push(executingTask);
+        this.modalRef.hide();
+        this.ngOnChanges();
+        this.taskManagerService.subscribe(executingTask.name, executingTask.metadata,
+          (asyncFinishedTask: FinishedTask) => {
+            this.notificationService.notifyTask(asyncFinishedTask);
+          });
+      })
+      .catch((resp) => {
+        this.modalRef.hide();
+        finishedTask.success = false;
+        finishedTask.exception = resp.error;
+        this.notificationService.notifyTask(finishedTask);
+      });
+  }
+
+  rollbackModal() {
+    const snapshotName = this.selection.selected[0].name;
+    this.modalRef = this.modalService.show(RollbackConfirmationModalComponent);
+    this.modalRef.content.snapName = `${this.poolName}/${this.rbdName}@${snapshotName}`;
+    this.modalRef.content.onSubmit.subscribe((itemName: string) => {
+      this.asyncTask('rollbackSnapshot', 'rbd/snap/rollback', snapshotName);
+    });
+  }
+
+  deleteSnapshotModal() {
+    const snapshotName = this.selection.selected[0].name;
+    this.modalRef = this.modalService.show(DeleteConfirmationComponent);
+    this.modalRef.content.itemName = snapshotName;
+    this.modalRef.content.onSubmit.subscribe((itemName: string) => {
+      this.asyncTask('deleteSnapshot', 'rbd/snap/delete', snapshotName);
+    });
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot.model.ts
new file mode 100644 (file)
index 0000000..06fd287
--- /dev/null
@@ -0,0 +1,9 @@
+export class RbdSnapshotModel {
+  id: number;
+  name: string;
+  size: number;
+  timestamp: string;
+  is_protected: boolean;
+
+  cdExecuting: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.html
new file mode 100644 (file)
index 0000000..e1ef7d4
--- /dev/null
@@ -0,0 +1,27 @@
+<div class="modal-header">
+  <h4 i18n
+      class="modal-title pull-left">RBD snapshot rollback</h4>
+  <button type="button" class="close pull-right" aria-label="Close" (click)="modalRef.hide()">
+    <span aria-hidden="true">&times;</span>
+  </button>
+</div>
+<form name="rollbackForm"
+      class="form-horizontal"
+      #formDir="ngForm"
+      [formGroup]="rollbackForm"
+      novalidate>
+  <div class="modal-body">
+    You are about to rollback <strong>{{ snapName }}</strong>.
+  </div>
+  <div class="modal-footer">
+    <div class="button-group text-right">
+      <cd-submit-button i18n
+                        [form]="rollbackForm"
+                        (submitAction)="submit()">
+        Rollback
+      </cd-submit-button>
+      <button i18n type="button" class="btn btn-sm btn-default" (click)="modalRef.hide()">Cancel</button>
+    </div>
+  </div>
+</form>
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..f041d49
--- /dev/null
@@ -0,0 +1,42 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { ToastModule } from 'ng2-toastr';
+import { BsModalRef, BsModalService } from 'ngx-bootstrap';
+
+import { ApiModule } from '../../../shared/api/api.module';
+import { ServicesModule } from '../../../shared/services/services.module';
+import { SharedModule } from '../../../shared/shared.module';
+import { RollbackConfirmationModalComponent } from './rollback-confimation-modal.component';
+
+describe('RollbackConfirmationModalComponent', () => {
+  let component: RollbackConfirmationModalComponent;
+  let fixture: ComponentFixture<RollbackConfirmationModalComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      imports: [
+        ReactiveFormsModule,
+        HttpClientTestingModule,
+        SharedModule,
+        ServicesModule,
+        ApiModule,
+        ToastModule.forRoot()
+      ],
+      declarations: [ RollbackConfirmationModalComponent ],
+      providers: [ BsModalRef, BsModalService ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RollbackConfirmationModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rollback-confirmation-modal/rollback-confimation-modal.component.ts
new file mode 100644 (file)
index 0000000..596c6e9
--- /dev/null
@@ -0,0 +1,35 @@
+import { Component, OnInit } from '@angular/core';
+import { FormGroup } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap';
+import { Subject } from 'rxjs/Subject';
+
+@Component({
+  selector: 'cd-rollback-confimation-modal',
+  templateUrl: './rollback-confimation-modal.component.html',
+  styleUrls: ['./rollback-confimation-modal.component.scss']
+})
+export class RollbackConfirmationModalComponent implements OnInit {
+
+  snapName: string;
+
+  rollbackForm: FormGroup;
+
+  public onSubmit: Subject<string>;
+
+  constructor(public modalRef: BsModalRef) {
+    this.createForm();
+  }
+
+  createForm() {
+    this.rollbackForm = new FormGroup({});
+  }
+
+  ngOnInit() {
+    this.onSubmit = new Subject();
+  }
+
+  submit() {
+    this.onSubmit.next(this.snapName);
+  }
+}
index 99bf8380912ca753a1d50ab2be32355217f4e310..5ed75f6296b0e408a6f4e91f4bbe80eac2d29c48 100644 (file)
         </a>
 
         <ul class="dropdown-menu">
+          <li routerLinkActive="active">
+            <a i18n
+               class="dropdown-item"
+               routerLink="/block/rbd">Images</a>
+          </li>
+
           <li routerLinkActive="active"
               class="tc_submenuitem tc_submenuitem_block_mirroring">
             <a i18n
                routerLink="/block/iscsi">iSCSI</a>
           </li>
 
-          <li class="dropdown-submenu">
-            <a class="dropdown-toggle"
-               data-toggle="dropdown">Pools</a>
-            <ul *dropdownMenu
-                class="dropdown-menu">
-              <li routerLinkActive="active"
-                  class="tc_submenuitem tc_submenuitem_pools"
-                  *ngFor="let rbdPool of rbdPools">
-                <a i18n
-                   class="dropdown-item"
-                   routerLink="/block/pool/{{ rbdPool }}">{{ rbdPool }}
-                </a>
-              </li>
-              <li class="tc_submenuitem tc_submenuitem_cephfs_nofs"
-                  *ngIf="rbdPools.length === 0">
-                <a class="dropdown-item disabled"
-                   i18n>There are no pools</a>
-              </li>
-            </ul>
-          </li>
         </ul>
       </li>
 
index dbb1af6d5a7d227bfefa7f201d8b3dc589db34c5..61b1c32e7561b3fa6641d0168563b077a8ddc987 100644 (file)
@@ -10,6 +10,7 @@ import { MonitorService } from './monitor.service';
 import { OsdService } from './osd.service';
 import { PoolService } from './pool.service';
 import { RbdMirroringService } from './rbd-mirroring.service';
+import { RbdService } from './rbd.service';
 import { RgwDaemonService } from './rgw-daemon.service';
 import { TablePerformanceCounterService } from './table-performance-counter.service';
 import { TcmuIscsiService } from './tcmu-iscsi.service';
@@ -26,6 +27,7 @@ import { TcmuIscsiService } from './tcmu-iscsi.service';
     MonitorService,
     OsdService,
     PoolService,
+    RbdService,
     RbdMirroringService,
     RgwDaemonService,
     TablePerformanceCounterService,
index 9589c6a8dd9df7794974b181f35944af551973af..96e9c8666c906401ed59e283b93cfa1cd65ada36 100644 (file)
@@ -7,8 +7,9 @@ export class PoolService {
   constructor(private http: HttpClient) {
   }
 
-  rbdPoolImages(pool) {
-    return this.http.get(`api/block/image?pool_name=${pool}`).toPromise().then((resp: any) => {
+  list(attrs = []) {
+    const attrsStr = attrs.join(',');
+    return this.http.get(`api/pool?attrs=${attrsStr}`).toPromise().then((resp: any) => {
       return resp;
     });
   }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts
new file mode 100644 (file)
index 0000000..c57f1ec
--- /dev/null
@@ -0,0 +1,67 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable()
+export class RbdService {
+
+  constructor(private http: HttpClient) {
+  }
+
+  create(rbd) {
+    return this.http.post('api/block/image', rbd, { observe: 'response' });
+  }
+
+  delete(poolName, rbdName) {
+    return this.http.delete(`api/block/image/${poolName}/${rbdName}`, { observe: 'response' });
+  }
+
+  update(poolName, rbdName, rbd) {
+    return this.http.put(`api/block/image/${poolName}/${rbdName}`, rbd, { observe: 'response' });
+  }
+
+  get(poolName, rbdName) {
+    return this.http.get(`api/block/image/${poolName}/${rbdName}`);
+  }
+
+  list() {
+    return this.http.get('api/block/image');
+  }
+
+  createSnapshot(poolName, rbdName, snapshotName) {
+    const request = {
+      snapshot_name: snapshotName
+    };
+    return this.http.post(`api/block/image/${poolName}/${rbdName}/snap`, request,
+      { observe: 'response' });
+  }
+
+  renameSnapshot(poolName, rbdName, snapshotName, newSnapshotName) {
+    const request = {
+      new_snap_name: newSnapshotName
+    };
+    return this.http.put(
+      `api/block/image/${poolName}/${rbdName}/snap/${snapshotName}`, request,
+        { observe: 'response' });
+  }
+
+  protectSnapshot(poolName, rbdName, snapshotName, isProtected) {
+    const request = {
+      is_protected: isProtected
+    };
+    return this.http.put(
+      `api/block/image/${poolName}/${rbdName}/snap/${snapshotName}`, request,
+      { observe: 'response' });
+  }
+
+  rollbackSnapshot(poolName, rbdName, snapshotName) {
+    return this.http.post(
+      `api/block/image/${poolName}/${rbdName}/snap/${snapshotName}/rollback`, null,
+      { observe: 'response' });
+  }
+
+  deleteSnapshot(poolName, rbdName, snapshotName) {
+    return this.http.delete(
+      `api/block/image/${poolName}/${rbdName}/snap/${snapshotName}`,
+      { observe: 'response' });
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/empty.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/empty.pipe.spec.ts
new file mode 100644 (file)
index 0000000..9e5b0b2
--- /dev/null
@@ -0,0 +1,8 @@
+import { EmptyPipe } from './empty.pipe';
+
+describe('EmptyPipe', () => {
+  it('create an instance', () => {
+    const pipe = new EmptyPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/empty.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/empty.pipe.ts
new file mode 100644 (file)
index 0000000..5c7845d
--- /dev/null
@@ -0,0 +1,14 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import * as _ from 'lodash';
+
+@Pipe({
+  name: 'empty'
+})
+export class EmptyPipe implements PipeTransform {
+
+  transform(value: any, args?: any): any {
+    return _.isUndefined(value) || _.isNull(value) ? '-' : value;
+  }
+
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/unix_errno.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/unix_errno.enum.ts
new file mode 100644 (file)
index 0000000..02bf31e
--- /dev/null
@@ -0,0 +1,4 @@
+// http://www.virtsync.com/c-error-codes-include-errno
+export enum UnixErrno {
+  EEXIST = 17, // File exists
+}
index bb0e238cc03f7513eb6a49790bef6e2ddd2d85ce..e5b1a24fa8cbbe12248f29ebb821581d65d4c240 100644 (file)
@@ -1,6 +1,7 @@
 import { CommonModule, DatePipe } from '@angular/common';
 import { NgModule } from '@angular/core';
 
+import { EmptyPipe } from '../empty.pipe';
 import { CdDatePipe } from './cd-date.pipe';
 import { CephShortVersionPipe } from './ceph-short-version.pipe';
 import { DimlessBinaryPipe } from './dimless-binary.pipe';
@@ -20,7 +21,8 @@ import { RelativeDatePipe } from './relative-date.pipe';
     RelativeDatePipe,
     ListPipe,
     FilterPipe,
-    CdDatePipe
+    CdDatePipe,
+    EmptyPipe
   ],
   exports: [
     DimlessBinaryPipe,
@@ -30,7 +32,8 @@ import { RelativeDatePipe } from './relative-date.pipe';
     RelativeDatePipe,
     ListPipe,
     FilterPipe,
-    CdDatePipe
+    CdDatePipe,
+    EmptyPipe
   ],
   providers: [
     DatePipe,
@@ -39,7 +42,8 @@ import { RelativeDatePipe } from './relative-date.pipe';
     DimlessPipe,
     RelativeDatePipe,
     ListPipe,
-    CdDatePipe
+    CdDatePipe,
+    EmptyPipe
   ]
 })
 export class PipesModule {}
index 1eb70e1305c4cc5c866d1167aa18af641ff0c1e7..cc4be50f0a024023f87bfc7c7c89fcae1127a3de 100644 (file)
@@ -20,6 +20,85 @@ class TaskManagerMessage {
 export class TaskManagerMessageService {
 
   messages = {
+    'rbd/create': new TaskManagerMessage(
+      (metadata) => `Create RBD '${metadata.pool_name}/${metadata.image_name}'`,
+      (metadata) => `RBD '${metadata.pool_name}/${metadata.image_name}'
+                     has been created successfully`,
+      (metadata) => {
+        return {
+          '17': `Name '${metadata.pool_name}/${metadata.image_name}' is already
+                 in use.`
+        };
+      }
+    ),
+    'rbd/edit': new TaskManagerMessage(
+      (metadata) => `Update RBD '${metadata.pool_name}/${metadata.image_name}'`,
+      (metadata) => `RBD '${metadata.pool_name}/${metadata.image_name}'
+                     has been updated successfully`,
+      (metadata) => {
+        return {
+          '17': `Name '${metadata.pool_name}/${metadata.image_name}' is already
+                 in use.`
+        };
+      }
+    ),
+    'rbd/delete': new TaskManagerMessage(
+      (metadata) => `Delete RBD '${metadata.pool_name}/${metadata.image_name}'`,
+      (metadata) => `RBD '${metadata.pool_name}/${metadata.image_name}'
+                     has been deleted successfully`,
+      (metadata) => {
+        return {
+          '39': `RBD image contains snapshots.`
+        };
+      }
+    ),
+    'rbd/snap/create': new TaskManagerMessage(
+      (metadata) => `Create snapshot ` +
+                    `'${metadata.pool_name}/${metadata.image_name}@${metadata.snapshot_name}'`,
+      (metadata) => `Snapshot ` +
+                    `'${metadata.pool_name}/${metadata.image_name}@${metadata.snapshot_name}' ` +
+                    `has been created successfully`,
+      (metadata) => {
+        return {
+          '17': `Name '${metadata.snapshot_name}' is already in use.`
+        };
+      }
+    ),
+    'rbd/snap/edit': new TaskManagerMessage(
+      (metadata) => `Update snapshot ` +
+                    `'${metadata.pool_name}/${metadata.image_name}@${metadata.snapshot_name}'`,
+      (metadata) => `Snapshot ` +
+                    `'${metadata.pool_name}/${metadata.image_name}@${metadata.snapshot_name}' ` +
+                    `has been updated successfully`,
+      () => {
+        return {
+          '16': `Cannot unprotect snapshot because it contains child images.`
+        };
+      }
+    ),
+    'rbd/snap/delete': new TaskManagerMessage(
+      (metadata) => `Delete snapshot ` +
+                    `'${metadata.pool_name}/${metadata.image_name}@${metadata.snapshot_name}'`,
+      (metadata) => `Snapshot ` +
+                    `'${metadata.pool_name}/${metadata.image_name}@${metadata.snapshot_name}' ` +
+                    `has been deleted successfully`,
+      () => {
+        return {
+          '16': `Snapshot is protected.`
+        };
+      }
+    ),
+    'rbd/snap/rollback': new TaskManagerMessage(
+      (metadata) => `Rollback snapshot ` +
+                    `'${metadata.pool_name}/${metadata.image_name}@${metadata.snapshot_name}'`,
+      (metadata) => `Snapshot ` +
+                    `'${metadata.pool_name}/${metadata.image_name}@${metadata.snapshot_name}' ` +
+                    `has been rolled back successfully`,
+      () => {
+        return {
+        };
+      }
+    ),
   };
 
   defaultMessage = new TaskManagerMessage(
index 1956d75b593f6d179c43154e872f0abab0ac4ce2..584ed0968170393020e136c14097060aa2cda4cf 100644 (file)
@@ -7,6 +7,9 @@ import { DataTableModule } from './datatable/datatable.module';
 import { DimlessBinaryDirective } from './directives/dimless-binary.directive';
 import { PasswordButtonDirective } from './directives/password-button.directive';
 import { PipesModule } from './pipes/pipes.module';
+import { AuthGuardService } from './services/auth-guard.service';
+import { AuthStorageService } from './services/auth-storage.service';
+import { FormatterService } from './services/formatter.service';
 import { ServicesModule } from './services/services.module';
 
 @NgModule({
@@ -30,6 +33,11 @@ import { ServicesModule } from './services/services.module';
     DimlessBinaryDirective,
     DataTableModule,
     ApiModule
+  ],
+  providers: [
+    AuthStorageService,
+    AuthGuardService,
+    FormatterService
   ]
 })
 export class SharedModule {}