--- /dev/null
+# -*- 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}
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
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):
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
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';
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({
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';
CoreModule,
SharedModule,
ToastModule.forRoot(),
- ClusterModule
+ ClusterModule,
+ BlockModule
],
declarations: [AppComponent]
}).compileComponents();
--- /dev/null
+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 { }
--- /dev/null
+<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>
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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;
+ }
+ });
+ }
+}
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';
imports: [
CommonModule,
ClusterModule,
- DashboardModule
+ DashboardModule,
+ BlockModule
],
declarations: []
})
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"
})
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() {}
}
--- /dev/null
+export enum ViewCacheStatus {
+ ValueOk = 0,
+ ValueStale = 1,
+ ValueNone = 2,
+ ValueException = 3
+}
--- /dev/null
+import { EllipsisPipe } from './ellipsis.pipe';
+
+describe('EllipsisPipe', () => {
+ it('create an instance', () => {
+ const pipe = new EllipsisPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+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;
+ }
+
+}
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({
DimlessBinaryPipe,
HealthColorPipe,
DimlessPipe,
- CephShortVersionPipe
+ CephShortVersionPipe,
+ EllipsisPipe
],
exports: [
DimlessBinaryPipe,
HealthColorPipe,
DimlessPipe,
- CephShortVersionPipe
+ CephShortVersionPipe,
+ EllipsisPipe
],
- providers: [DimlessBinaryPipe]
+ providers: [
+ DimlessBinaryPipe,
+ DimlessPipe
+ ]
})
export class PipesModule {}
--- /dev/null
+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;
+ });
+ }
+}
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';
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({
ComponentsModule,
ServicesModule
],
- exports: [PipesModule, ServicesModule, PipesModule],
+ exports: [PipesModule, ServicesModule],
declarations: [],
providers: [
AuthService,
AuthStorageService,
AuthGuardService,
- HostService
+ HostService,
+ PoolService,
+ FormatterService
]
})
export class SharedModule {}
font-style: italic;
}
+text-right {
+ text-align: right;
+}
+
/* Branding */
.navbar-openattic .navbar-brand,
.navbar-openattic .navbar-brand:hover{
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;
+}
--- /dev/null
+# -*- 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')
class DashboardTest(ControllerTestCase):
+
@authenticate
def test_toplevel(self):
data = self._get("/api/dashboard/toplevel")
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):