]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard_v2: Add block pools page
authorRicardo Marques <rimarques@suse.com>
Wed, 31 Jan 2018 13:57:36 +0000 (13:57 +0000)
committerRicardo Dias <rdias@suse.com>
Mon, 5 Mar 2018 13:07:07 +0000 (13:07 +0000)
Signed-off-by: Ricardo Marques <rimarques@suse.com>
22 files changed:
src/pybind/mgr/dashboard_v2/controllers/block_pool.py [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/controllers/dashboard.py
src/pybind/mgr/dashboard_v2/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/app.component.spec.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/block.module.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/ceph.module.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard_v2/frontend/src/app/core/navigation/navigation/navigation.component.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/enum/view-cache-status.enum.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/ellipsis.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/ellipsis.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/pipes.module.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/pool.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/top-level.service.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/shared.module.ts
src/pybind/mgr/dashboard_v2/frontend/src/openattic-theme.scss
src/pybind/mgr/dashboard_v2/tests/test_block_pool.py [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/tests/test_dashboard.py

diff --git a/src/pybind/mgr/dashboard_v2/controllers/block_pool.py b/src/pybind/mgr/dashboard_v2/controllers/block_pool.py
new file mode 100644 (file)
index 0000000..c62f350
--- /dev/null
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import cherrypy
+import rbd
+
+from ..tools import ApiController, AuthRequired, BaseController, ViewCache
+
+
+@ApiController('block_pool')
+@AuthRequired()
+class BlockPool(BaseController):
+
+    def __init__(self):
+        self.rbd = None
+
+    def _format_bitmask(self, features):
+        RBD_FEATURES_NAME_MAPPING = {
+            rbd.RBD_FEATURE_LAYERING: "layering",
+            rbd.RBD_FEATURE_STRIPINGV2: "striping",
+            rbd.RBD_FEATURE_EXCLUSIVE_LOCK: "exclusive-lock",
+            rbd.RBD_FEATURE_OBJECT_MAP: "object-map",
+            rbd.RBD_FEATURE_FAST_DIFF: "fast-diff",
+            rbd.RBD_FEATURE_DEEP_FLATTEN: "deep-flatten",
+            rbd.RBD_FEATURE_JOURNALING: "journaling",
+            rbd.RBD_FEATURE_DATA_POOL: "data-pool",
+            rbd.RBD_FEATURE_OPERATIONS: "operations",
+        }
+        names = [val for key, val in RBD_FEATURES_NAME_MAPPING.items()
+                 if key & features == key]
+        return ', '.join(sorted(names))
+
+    @ViewCache()
+    def _rbd_list(self, pool_name):
+        ioctx = self.mgr.rados.open_ioctx(pool_name)
+        self.rbd = rbd.RBD()
+        names = self.rbd.list(ioctx)
+        result = []
+        for name in names:
+            i = rbd.Image(ioctx, name)
+            stat = i.stat()
+            stat['name'] = name
+            features = i.features()
+            stat['features'] = features
+            stat['features_name'] = self._format_bitmask(features)
+
+            try:
+                parent_info = i.parent_info()
+                parent = "{}@{}".format(parent_info[0], parent_info[1])
+                if parent_info[0] != pool_name:
+                    parent = "{}/{}".format(parent_info[0], parent)
+                stat['parent'] = parent
+            except rbd.ImageNotFound:
+                pass
+            result.append(stat)
+        return result
+
+    @cherrypy.expose
+    @cherrypy.tools.json_out()
+    def rbd_pool_data(self, pool_name):
+        # pylint: disable=unbalanced-tuple-unpacking
+        status, value = self._rbd_list(pool_name)
+        if status == ViewCache.VALUE_EXCEPTION:
+            raise value
+        return {'status': status, 'value': value}
index ea1c06d98f45947c75d4f2e85c93e4ee7c384043..1ec3bd0d79c20621132d9c3f4847efe9c101f4de 100644 (file)
@@ -9,7 +9,7 @@ import time
 import cherrypy
 from mgr_module import CommandResult
 
-from ..tools import ApiController, AuthRequired, BaseController, NotificationQueue
+from ..tools import ApiController, AuthRequired, BaseController, NotificationQueue, ViewCache
 
 
 LOG_BUFFER_SIZE = 30
@@ -54,6 +54,13 @@ class Dashboard(BaseController):
                 for l in lines:
                     buf.appendleft(l)
 
+    @ViewCache()
+    def _rbd_pool_ls(self):
+        osd_map = self.mgr.get("osd_map")
+        rbd_pools = [pool['pool_name'] for pool in osd_map['pools'] if
+                     'rbd' in pool.get('application_metadata', {})]
+        return rbd_pools
+
     @cherrypy.expose
     @cherrypy.tools.json_out()
     def toplevel(self):
@@ -67,9 +74,16 @@ class Dashboard(BaseController):
             for f in fsmap['filesystems']
         ]
 
+        _, data = self._rbd_pool_ls()
+        if data is None:
+            self.mgr.log.warning("Failed to get RBD pool list")
+            data = []
+        data.sort()
+
         return {
             'health_status': self.health_data()['status'],
             'filesystems': filesystems,
+            'rbd_pools': data
         }
 
     # pylint: disable=R0914
index 237867c83ec597024585eaa9827b205d390a79fd..231da6ae7908397dcce4a9e15feb50e2ed44b55d 100644 (file)
@@ -1,6 +1,7 @@
 import { NgModule } from '@angular/core';
 import { RouterModule, Routes } from '@angular/router';
 
+import { PoolDetailComponent } from './ceph/block/pool-detail/pool-detail.component';
 import { HostsComponent } from './ceph/cluster/hosts/hosts.component';
 import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component';
 import { LoginComponent } from './core/auth/login/login.component';
@@ -14,7 +15,8 @@ const routes: Routes = [
     canActivate: [AuthGuardService]
   },
   { path: 'login', component: LoginComponent },
-  { path: 'hosts', component: HostsComponent, canActivate: [AuthGuardService] }
+  { path: 'hosts', component: HostsComponent, canActivate: [AuthGuardService] },
+  { path: 'block/pool/:name', component: PoolDetailComponent, canActivate: [AuthGuardService] }
 ];
 
 @NgModule({
index e7925a0743f2da3cf3d2e85636eb733fa0960fb5..3cca10d09170b806e647e6f0b626b1063f4588e8 100644 (file)
@@ -4,6 +4,7 @@ import { RouterTestingModule } from '@angular/router/testing';
 import { ToastModule } from 'ng2-toastr';
 
 import { AppComponent } from './app.component';
+import { BlockModule } from './ceph/block/block.module';
 import { ClusterModule } from './ceph/cluster/cluster.module';
 import { CoreModule } from './core/core.module';
 import { SharedModule } from './shared/shared.module';
@@ -17,7 +18,8 @@ describe('AppComponent', () => {
           CoreModule,
           SharedModule,
           ToastModule.forRoot(),
-          ClusterModule
+          ClusterModule,
+          BlockModule
         ],
         declarations: [AppComponent]
       }).compileComponents();
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/block.module.ts
new file mode 100644 (file)
index 0000000..da6c9c1
--- /dev/null
@@ -0,0 +1,24 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+import { AlertModule, TabsModule } from 'ngx-bootstrap';
+
+import { ComponentsModule } from '../../shared/components/components.module';
+import { PipesModule } from '../../shared/pipes/pipes.module';
+import { SharedModule } from '../../shared/shared.module';
+import { PoolDetailComponent } from './pool-detail/pool-detail.component';
+
+@NgModule({
+  imports: [
+    CommonModule,
+    FormsModule,
+    TabsModule.forRoot(),
+    AlertModule.forRoot(),
+    SharedModule,
+    ComponentsModule,
+    PipesModule
+  ],
+  declarations: [PoolDetailComponent]
+})
+export class BlockModule { }
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.html
new file mode 100644 (file)
index 0000000..6bad017
--- /dev/null
@@ -0,0 +1,34 @@
+<nav aria-label="breadcrumb">
+  <ol class="breadcrumb">
+    <li class="breadcrumb-item">Block</li>
+    <li class="breadcrumb-item">Pools</li>
+    <li class="breadcrumb-item active" aria-current="page">{{ name }}</li>
+  </ol>
+</nav>
+
+<legend>
+  Images
+  <small>
+    <i class="fa fa-spinner fa-spin fa-fw"
+       aria-hidden="true"
+       *ngIf="images === null && retries <= maxRetries"></i>
+  </small>
+</legend>
+
+<alert type="warning" *ngIf="images === null && retries > 0 && retries <= maxRetries">
+  Still working, please wait{{ retries | ellipsis }}
+</alert>
+
+<alert type="danger" *ngIf="images === null && retries > maxRetries">
+  Cannot load images within {{ name }}.
+</alert>
+
+<div *ngIf="images !== null && images.length === 0">
+  There are no images within {{ name }} pool.
+</div>
+
+<cd-table [data]="images"
+          [columns]="columns"
+          (fetchData)="loadImages()"
+          *ngIf="images !== null && images.length > 0">
+</cd-table>
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.scss b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.spec.ts
new file mode 100644 (file)
index 0000000..6a208af
--- /dev/null
@@ -0,0 +1,39 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { AlertModule, 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,
+        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_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/block/pool-detail/pool-detail.component.ts
new file mode 100644 (file)
index 0000000..b93a98f
--- /dev/null
@@ -0,0 +1,95 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+
+import { ViewCacheStatus } from '../../../shared/enum/view-cache-status.enum';
+import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '../../../shared/pipes/dimless.pipe';
+import { FormatterService } from '../../../shared/services/formatter.service';
+import { PoolService } from '../../../shared/services/pool.service';
+
+@Component({
+  selector: 'cd-pool-detail',
+  templateUrl: './pool-detail.component.html',
+  styleUrls: ['./pool-detail.component.scss']
+})
+export class PoolDetailComponent implements OnInit, OnDestroy {
+
+  name: string;
+  images: any;
+  columns: any;
+  retries: number;
+  maxRetries = 5;
+  routeParamsSubscribe: any;
+
+  constructor(private route: ActivatedRoute,
+              private poolService: PoolService,
+              dimlessBinaryPipe: DimlessBinaryPipe,
+              dimlessPipe: DimlessPipe) {
+    this.columns = [
+      {
+        name: 'Name',
+        prop: 'name',
+        width: 100
+      },
+      {
+        name: 'Size',
+        prop: 'size',
+        width: 50,
+        cellClass: 'text-right',
+        pipe: dimlessBinaryPipe
+      },
+      {
+        name: 'Objects',
+        prop: 'num_objs',
+        width: 50,
+        cellClass: 'text-right',
+        pipe: dimlessPipe
+      },
+      {
+        name: 'Object size',
+        prop: 'obj_size',
+        width: 50,
+        cellClass: 'text-right',
+        pipe: dimlessBinaryPipe
+      },
+      {
+        name: 'Features',
+        prop: 'features_name',
+        width: 150},
+      {
+        name: 'Parent',
+        prop: 'parent',
+        width: 100
+      }
+    ];
+  }
+
+  ngOnInit() {
+    this.routeParamsSubscribe = this.route.params.subscribe((params: { name: string }) => {
+      this.name = params.name;
+      this.images = null;
+      this.retries = 0;
+      this.loadImages();
+    });
+  }
+
+  ngOnDestroy() {
+    this.routeParamsSubscribe.unsubscribe();
+  }
+
+  loadImages() {
+    this.poolService.rbdPoolImages(this.name).then((resp) => {
+      if (resp.status === ViewCacheStatus.ValueNone) {
+        setTimeout(() => {
+          this.retries++;
+          if (this.retries <= this.maxRetries) {
+            this.loadImages();
+          }
+        }, 1000);
+      } else {
+        this.retries = 0;
+        this.images = resp.value;
+      }
+    });
+  }
+}
index 00f9548728cbbdb10b38ce86bb540a32fc8070e4..2bdca2340d5ee3c4719056892ea99124d8ec76d1 100644 (file)
@@ -1,6 +1,7 @@
 import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
 
+import { BlockModule } from './block/block.module';
 import { ClusterModule } from './cluster/cluster.module';
 import { DashboardModule } from './dashboard/dashboard.module';
 
@@ -8,7 +9,8 @@ import { DashboardModule } from './dashboard/dashboard.module';
   imports: [
     CommonModule,
     ClusterModule,
-    DashboardModule
+    DashboardModule,
+    BlockModule
   ],
   declarations: []
 })
index e93c304f21a4382c4348fdbebbcd3b425abd465e..2d3a261ea638d5043702a7f32036a437b03aa432 100644 (file)
            routerLink="/cephOsds">OSDs
         </a>
       </li>
-      <li routerLinkActive="active"
-          class="tc_menuitem tc_menuitem_ceph_rbds">
-        <a i18n
-           routerLink="/cephRbds">RBDs
-        </a>
-      </li>
       <li routerLinkActive="active"
           class="tc_menuitem tc_menuitem_ceph_pools">
         <a i18n
           </li>
         </ul>
       </li>
+      <!-- Block -->
+      <li dropdown
+          routerLinkActive="active"
+          class="dropdown tc_menuitem tc_menuitem_block">
+        <a dropdownToggle
+           class="dropdown-toggle"
+           data-toggle="dropdown">
+          <ng-container i18n>Block</ng-container>
+          <span class="caret"></span>
+        </a>
+
+        <ul class="dropdown-menu">
+          <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>
       <!--<li class="dropdown tc_menuitem tc_menuitem_ceph_rgw">
         <a href=""
            class="dropdown-toggle"
index 102798d11b39523e13627b39dbb86938ca0ced5a..37fae9d9e747e054e133a227cd8412d9a86431d1 100644 (file)
@@ -8,12 +8,14 @@ import { TopLevelService } from '../../../shared/services/top-level.service';
 })
 export class NavigationComponent implements OnInit {
   topLevelData: any;
+  rbdPools: Array<any> = [];
 
-  constructor(topLevelService: TopLevelService) {
-    topLevelService.topLevelData$.subscribe(data => {
+  constructor(private topLevelService: TopLevelService) {}
+
+  ngOnInit() {
+    this.topLevelService.topLevelData$.subscribe((data: any) => {
       this.topLevelData = data;
+      this.rbdPools = data.rbd_pools;
     });
   }
-
-  ngOnInit() {}
 }
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/enum/view-cache-status.enum.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/enum/view-cache-status.enum.ts
new file mode 100644 (file)
index 0000000..169059c
--- /dev/null
@@ -0,0 +1,6 @@
+export enum ViewCacheStatus {
+  ValueOk = 0,
+  ValueStale = 1,
+  ValueNone = 2,
+  ValueException = 3
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/ellipsis.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/ellipsis.pipe.spec.ts
new file mode 100644 (file)
index 0000000..cda1a3c
--- /dev/null
@@ -0,0 +1,8 @@
+import { EllipsisPipe } from './ellipsis.pipe';
+
+describe('EllipsisPipe', () => {
+  it('create an instance', () => {
+    const pipe = new EllipsisPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/ellipsis.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/ellipsis.pipe.ts
new file mode 100644 (file)
index 0000000..4208fea
--- /dev/null
@@ -0,0 +1,16 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'ellipsis'
+})
+export class EllipsisPipe implements PipeTransform {
+
+  transform(value: any, args?: any): any {
+    let result = '';
+    for (let i = 0; i < value; i++) {
+      result += '...';
+    }
+    return result;
+  }
+
+}
index aa4d4739e9dd14e4869d0b1b0f338f4983fe61cd..a140cf9a162101082a142028ade997f79c3282b9 100644 (file)
@@ -4,6 +4,7 @@ import { NgModule } from '@angular/core';
 import { CephShortVersionPipe } from './ceph-short-version.pipe';
 import { DimlessBinaryPipe } from './dimless-binary.pipe';
 import { DimlessPipe } from './dimless.pipe';
+import { EllipsisPipe } from './ellipsis.pipe';
 import { HealthColorPipe } from './health-color.pipe';
 
 @NgModule({
@@ -12,14 +13,19 @@ import { HealthColorPipe } from './health-color.pipe';
     DimlessBinaryPipe,
     HealthColorPipe,
     DimlessPipe,
-    CephShortVersionPipe
+    CephShortVersionPipe,
+    EllipsisPipe
   ],
   exports: [
     DimlessBinaryPipe,
     HealthColorPipe,
     DimlessPipe,
-    CephShortVersionPipe
+    CephShortVersionPipe,
+    EllipsisPipe
   ],
-  providers: [DimlessBinaryPipe]
+  providers: [
+    DimlessBinaryPipe,
+    DimlessPipe
+  ]
 })
 export class PipesModule {}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/pool.service.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/pool.service.ts
new file mode 100644 (file)
index 0000000..1f404f7
--- /dev/null
@@ -0,0 +1,15 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable()
+export class PoolService {
+
+  constructor(private http: HttpClient) {
+  }
+
+  rbdPoolImages(pool) {
+    return this.http.get(`/api/block_pool/rbd_pool_data/${pool}`).toPromise().then((resp: any) => {
+      return resp;
+    });
+  }
+}
index cee098d664f34212accce2375bb8f317453b5dd3..5df5ee927570816561aba29a81529ac1b4a42102 100644 (file)
@@ -1,7 +1,6 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
-import * as _ from 'lodash';
 import { Subject } from 'rxjs/Subject';
 
 import { AuthStorageService } from './auth-storage.service';
index ac14d9f3313157c79f432d18e87d988cbf7a3d43..766db6401d0aa1542f8ffa9c9ab4ed6dbcdb4356 100644 (file)
@@ -6,7 +6,9 @@ import { PipesModule } from './pipes/pipes.module';
 import { AuthGuardService } from './services/auth-guard.service';
 import { AuthStorageService } from './services/auth-storage.service';
 import { AuthService } from './services/auth.service';
+import { FormatterService } from './services/formatter.service';
 import { HostService } from './services/host.service';
+import { PoolService } from './services/pool.service';
 import { ServicesModule } from './services/services.module';
 
 @NgModule({
@@ -16,13 +18,15 @@ import { ServicesModule } from './services/services.module';
     ComponentsModule,
     ServicesModule
   ],
-  exports: [PipesModule, ServicesModule, PipesModule],
+  exports: [PipesModule, ServicesModule],
   declarations: [],
   providers: [
     AuthService,
     AuthStorageService,
     AuthGuardService,
-    HostService
+    HostService,
+    PoolService,
+    FormatterService
   ]
 })
 export class SharedModule {}
index 1e9de270941fb0b1eb976ab86b0f36ffae6fb857..08f0330420ba2fa2eedc50f448216aac4d18fc89 100755 (executable)
@@ -107,6 +107,10 @@ option {
   font-style: italic;
 }
 
+text-right {
+  text-align: right;
+}
+
 /* Branding */
 .navbar-openattic .navbar-brand,
 .navbar-openattic .navbar-brand:hover{
@@ -1214,3 +1218,51 @@ hr.oa-hr-small {
   border-left: 1px solid #ccc;
   border-right: 1px solid #ccc;
 }
+
+.dropdown-submenu {
+    position: relative;
+}
+
+.dropdown-submenu>.dropdown-menu {
+    top: 0;
+    left: 100%;
+    margin-top: -6px;
+    margin-left: -1px;
+    -webkit-border-radius: 0 6px 6px 6px;
+    -moz-border-radius: 0 6px 6px;
+    border-radius: 0 6px 6px 6px;
+}
+
+.dropdown-submenu:hover>.dropdown-menu {
+    display: block;
+}
+
+.dropdown-submenu>a:after {
+    display: block;
+    content: " ";
+    float: right;
+    width: 0;
+    height: 0;
+    border-color: transparent;
+    border-style: solid;
+    border-width: 5px 0 5px 5px;
+    border-left-color: $oa-color-blue;
+    margin-top: 5px;
+    margin-right: -10px;
+}
+
+.dropdown-submenu:hover>a:after {
+    border-left-color: $oa-color-blue;
+}
+
+.dropdown-submenu.pull-left {
+    float: none;
+}
+
+.dropdown-submenu.pull-left>.dropdown-menu {
+    left: -100%;
+    margin-left: 10px;
+    -webkit-border-radius: 6px 0 6px 6px;
+    -moz-border-radius: 6px 0 6px 6px;
+    border-radius: 6px 0 6px 6px;
+}
diff --git a/src/pybind/mgr/dashboard_v2/tests/test_block_pool.py b/src/pybind/mgr/dashboard_v2/tests/test_block_pool.py
new file mode 100644 (file)
index 0000000..cc914f2
--- /dev/null
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import absolute_import
+
+from .helper import ControllerTestCase, authenticate
+
+
+class BlockPoolTest(ControllerTestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls._ceph_cmd(['osd', 'pool', 'create', 'rbd', '100', '100'])
+        cls._ceph_cmd(['osd', 'pool', 'application', 'enable', 'rbd', 'rbd'])
+        cls._rbd_cmd(['create', '--size=1G', 'img1'])
+        cls._rbd_cmd(['create', '--size=2G', 'img2'])
+
+    @classmethod
+    def tearDownClass(cls):
+        cls._ceph_cmd(['osd', 'pool', 'delete', 'rbd', '--yes-i-really-really-mean-it'])
+
+    @authenticate
+    def test_list(self):
+        data = self._get('/api/block_pool/rbd_pool_data/rbd')
+        self.assertStatus(200)
+
+        img1 = data['value'][0]
+        self.assertEqual(img1['name'], 'img1')
+        self.assertEqual(img1['size'], 1073741824)
+        self.assertEqual(img1['num_objs'], 256)
+        self.assertEqual(img1['obj_size'], 4194304)
+        self.assertEqual(img1['features_name'],
+                         'deep-flatten, exclusive-lock, fast-diff, layering, object-map')
+
+        img2 = data['value'][1]
+        self.assertEqual(img2['name'], 'img2')
+        self.assertEqual(img2['size'], 2147483648)
+        self.assertEqual(img2['num_objs'], 512)
+        self.assertEqual(img2['obj_size'], 4194304)
+        self.assertEqual(img2['features_name'],
+                         'deep-flatten, exclusive-lock, fast-diff, layering, object-map')
index 0c7e08c0a3d2cbea50557e33a6b628052488bb33..739cb24bf8d5021b8996ad940384adb61e5e3236 100644 (file)
@@ -5,6 +5,7 @@ from .helper import ControllerTestCase, authenticate
 
 
 class DashboardTest(ControllerTestCase):
+
     @authenticate
     def test_toplevel(self):
         data = self._get("/api/dashboard/toplevel")
@@ -12,8 +13,10 @@ class DashboardTest(ControllerTestCase):
 
         self.assertIn('filesystems', data)
         self.assertIn('health_status', data)
+        self.assertIn('rbd_pools', data)
         self.assertIsNotNone(data['filesystems'])
         self.assertIsNotNone(data['health_status'])
+        self.assertIsNotNone(data['rbd_pools'])
 
     @authenticate
     def test_health(self):