]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard_v2: Add RGW page.
authorVolker Theile <vtheile@suse.com>
Mon, 5 Feb 2018 13:56:46 +0000 (14:56 +0100)
committerRicardo Dias <rdias@suse.com>
Mon, 5 Mar 2018 13:07:09 +0000 (13:07 +0000)
Signed-off-by: Volker Theile <vtheile@suse.com>
45 files changed:
src/pybind/mgr/dashboard_v2/controllers/perf_counters.py [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/ceph.module.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/cluster/hosts/hosts.component.html
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/performance-counter.module.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/services/table-performance-counter.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/services/table-performance-counter.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw.module.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/services/rgw-daemon.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/services/rgw-daemon.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/datatable.module.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-details.directive.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-details.directive.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.spec.ts [deleted file]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.ts [deleted file]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.html [deleted file]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.scss [deleted file]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.spec.ts [deleted file]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.ts [deleted file]
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/module.py
src/pybind/mgr/dashboard_v2/tests/test_perf_counters.py [new file with mode: 0644]

diff --git a/src/pybind/mgr/dashboard_v2/controllers/perf_counters.py b/src/pybind/mgr/dashboard_v2/controllers/perf_counters.py
new file mode 100644 (file)
index 0000000..276b7e3
--- /dev/null
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+from ..tools import ApiController, AuthRequired, RESTController
+
+
+class PerfCounter(RESTController):
+    def __init__(self, service_type, mgr):
+        PerfCounter.mgr = mgr
+        self._service_type = service_type
+
+    def _get_rate(self, daemon_type, daemon_name, stat):
+        data = self.mgr.get_counter(daemon_type, daemon_name, stat)[stat]
+        if data and len(data) > 1:
+            return (data[-1][1] - data[-2][1]) / float(data[-1][0] - data[-2][0])
+        return 0
+
+    def _get_latest(self, daemon_type, daemon_name, stat):
+        data = self.mgr.get_counter(daemon_type, daemon_name, stat)[stat]
+        if data:
+            return data[-1][1]
+        return 0
+
+    def get(self, service_id):
+        schema = self.mgr.get_perf_schema(
+            self._service_type, str(service_id)).values()[0]
+        counters = []
+
+        for key, value in sorted(schema.items()):
+            counter = dict()
+            counter['name'] = str(key)
+            counter['description'] = value['description']
+            # pylint: disable=W0212
+            if self.mgr._stattype_to_str(value['type']) == 'counter':
+                counter['value'] = self._get_rate(
+                    self._service_type, service_id, key)
+                counter['unit'] = '/s'
+            else:
+                counter['value'] = self._get_latest(
+                    self._service_type, service_id, key)
+                counter['unit'] = ''
+            counters.append(counter)
+
+        return {
+            'service': {
+                'type': self._service_type,
+                'id': service_id
+            },
+            'counters': counters
+        }
+
+
+@ApiController('perf_counters')
+@AuthRequired()
+class PerfCounters(RESTController):
+    def __init__(self):
+        self.mon = PerfCounter('mon', self.mgr)
+        self.osd = PerfCounter('osd', self.mgr)
+        self.rgw = PerfCounter('rgw', self.mgr)
+
+    def list(self):
+        counters = self.mgr.get_all_perf_counters()
+        return counters
index 231da6ae7908397dcce4a9e15feb50e2ed44b55d..490bce77f8941c97de86d17529f5163bd657f025 100644 (file)
@@ -4,6 +4,7 @@ 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 { RgwDaemonListComponent } from './ceph/rgw/rgw-daemon-list/rgw-daemon-list.component';
 import { LoginComponent } from './core/auth/login/login.component';
 import { AuthGuardService } from './shared/services/auth-guard.service';
 
@@ -16,6 +17,11 @@ const routes: Routes = [
   },
   { path: 'login', component: LoginComponent },
   { path: 'hosts', component: HostsComponent, canActivate: [AuthGuardService] },
+  {
+    path: 'rgw',
+    component: RgwDaemonListComponent,
+    canActivate: [AuthGuardService]
+  },
   { path: 'block/pool/:name', component: PoolDetailComponent, canActivate: [AuthGuardService] }
 ];
 
index 2bdca2340d5ee3c4719056892ea99124d8ec76d1..97439a436780544e4d6167975bd3177e741b377e 100644 (file)
@@ -4,12 +4,14 @@ import { NgModule } from '@angular/core';
 import { BlockModule } from './block/block.module';
 import { ClusterModule } from './cluster/cluster.module';
 import { DashboardModule } from './dashboard/dashboard.module';
+import { RgwModule } from './rgw/rgw.module';
 
 @NgModule({
   imports: [
     CommonModule,
     ClusterModule,
     DashboardModule,
+    RgwModule,
     BlockModule
   ],
   declarations: []
index 2ad38a7c2a0897e89d3f087506f5307356ad4f75..de0524977ce36c759d98c5d56e1b321559882e6a 100644 (file)
@@ -8,5 +8,6 @@
           [columns]="columns"
           columnMode="flex"
           [toolHeader]="false"
-          [footer]="false">
+          [footer]="false"
+          [limit]="0">
 </cd-table>
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/performance-counter.module.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/performance-counter.module.ts
new file mode 100644 (file)
index 0000000..18ff9ca
--- /dev/null
@@ -0,0 +1,23 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { SharedModule } from '../../shared/shared.module';
+import { TablePerformanceCounterService } from './services/table-performance-counter.service';
+import { TablePerformanceCounterComponent } from './table-performance-counter/table-performance-counter.component'; // tslint:disable-line
+
+@NgModule({
+  imports: [
+    CommonModule,
+    SharedModule
+  ],
+  declarations: [
+    TablePerformanceCounterComponent
+  ],
+  providers: [
+    TablePerformanceCounterService
+  ],
+  exports: [
+    TablePerformanceCounterComponent
+  ]
+})
+export class PerformanceCounterModule { }
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/services/table-performance-counter.service.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/services/table-performance-counter.service.spec.ts
new file mode 100644 (file)
index 0000000..37350f7
--- /dev/null
@@ -0,0 +1,21 @@
+import { HttpClientModule } from '@angular/common/http';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { inject, TestBed } from '@angular/core/testing';
+
+import { TablePerformanceCounterService } from './table-performance-counter.service';
+
+describe('TablePerformanceCounterService', () => {
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [TablePerformanceCounterService],
+      imports: [HttpClientTestingModule, HttpClientModule]
+    });
+  });
+
+  it(
+    'should be created',
+    inject([TablePerformanceCounterService], (service: TablePerformanceCounterService) => {
+      expect(service).toBeTruthy();
+    })
+  );
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/services/table-performance-counter.service.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/services/table-performance-counter.service.ts
new file mode 100644 (file)
index 0000000..2f6ecf3
--- /dev/null
@@ -0,0 +1,26 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable()
+export class TablePerformanceCounterService {
+
+  private url = '/api/perf_counters';
+
+  constructor(private http: HttpClient) { }
+
+  list() {
+    return this.http.get(this.url)
+      .toPromise()
+      .then((resp: object): object => {
+        return resp;
+      });
+  }
+
+  get(service_type: string, service_id: string) {
+    return this.http.get(`${this.url}/${service_type}/${service_id}`)
+      .toPromise()
+      .then((resp: object): Array<object> => {
+        return resp['counters'];
+      });
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.html
new file mode 100644 (file)
index 0000000..dd95479
--- /dev/null
@@ -0,0 +1,10 @@
+<cd-table [data]="counters"
+          [columns]="columns"
+          columnMode="flex"
+          [toolHeader]="false"
+          [footer]="false"
+          [limit]="0">
+  <ng-template #valueTpl let-row="row">
+    {{ row.value | dimless }} {{ row.unit }}
+  </ng-template>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.scss b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.spec.ts
new file mode 100644 (file)
index 0000000..dae55c6
--- /dev/null
@@ -0,0 +1,35 @@
+import { HttpClientModule } from '@angular/common/http';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { SharedModule } from '../../../shared/shared.module';
+import { TablePerformanceCounterService } from '../services/table-performance-counter.service';
+import { TablePerformanceCounterComponent } from './table-performance-counter.component';
+
+describe('TablePerformanceCounterComponent', () => {
+  let component: TablePerformanceCounterComponent;
+  let fixture: ComponentFixture<TablePerformanceCounterComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ TablePerformanceCounterComponent ],
+      imports: [
+        HttpClientTestingModule,
+        HttpClientModule,
+        SharedModule
+      ],
+      providers: [ TablePerformanceCounterService ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(TablePerformanceCounterComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/performance-counter/table-performance-counter/table-performance-counter.component.ts
new file mode 100644 (file)
index 0000000..24ef948
--- /dev/null
@@ -0,0 +1,60 @@
+import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
+
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { TablePerformanceCounterService } from '../services/table-performance-counter.service';
+
+/**
+ * Display the specified performance counters in a datatable.
+ */
+@Component({
+  selector: 'cd-table-performance-counter',
+  templateUrl: './table-performance-counter.component.html',
+  styleUrls: ['./table-performance-counter.component.scss']
+})
+export class TablePerformanceCounterComponent implements OnInit {
+
+  private columns: Array<CdTableColumn> = [];
+  private counters: Array<object> = [];
+
+  @ViewChild('valueTpl') public valueTpl: TemplateRef<any>;
+
+  /**
+   * The service type, e.g. 'rgw', 'mds', 'mon', 'osd', ...
+   */
+  @Input() serviceType: string;
+
+  /**
+   * The service identifier.
+   */
+  @Input() serviceId: string;
+
+  constructor(private performanceCounterService: TablePerformanceCounterService) { }
+
+  ngOnInit() {
+    this.columns = [
+      {
+        name: 'Name',
+        prop: 'name',
+        flexGrow: 1
+      },
+      {
+        name: 'Description',
+        prop: 'description',
+        flexGrow: 1
+      },
+      {
+        name: 'Value',
+        cellTemplate: this.valueTpl,
+        flexGrow: 1
+      }
+    ];
+    this.getCounters();
+  }
+
+  getCounters() {
+    this.performanceCounterService.get(this.serviceType, this.serviceId)
+      .then((resp) => {
+        this.counters = resp;
+      });
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.html
new file mode 100644 (file)
index 0000000..83fd492
--- /dev/null
@@ -0,0 +1,11 @@
+<tabset>
+  <tab heading="Details">
+    <cd-table-key-value [data]="metadata">
+    </cd-table-key-value>
+  </tab>
+  <tab heading="Performance Counters">
+    <cd-table-performance-counter serviceType="rgw"
+                                  [serviceId]="serviceId">
+    </cd-table-performance-counter>
+  </tab>
+</tabset>
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.scss b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.spec.ts
new file mode 100644 (file)
index 0000000..d48f1cc
--- /dev/null
@@ -0,0 +1,40 @@
+import { HttpClientModule } from '@angular/common/http';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TabsModule } from 'ngx-bootstrap/tabs';
+
+import { SharedModule } from '../../../shared/shared.module';
+import { PerformanceCounterModule } from '../../performance-counter/performance-counter.module';
+import { RgwDaemonService } from '../services/rgw-daemon.service';
+import { RgwDaemonDetailsComponent } from './rgw-daemon-details.component';
+
+describe('RgwDaemonDetailsComponent', () => {
+  let component: RgwDaemonDetailsComponent;
+  let fixture: ComponentFixture<RgwDaemonDetailsComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ RgwDaemonDetailsComponent ],
+      imports: [
+        SharedModule,
+        PerformanceCounterModule,
+        HttpClientTestingModule,
+        HttpClientModule,
+        TabsModule.forRoot()
+      ],
+      providers: [ RgwDaemonService ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwDaemonDetailsComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.ts
new file mode 100644 (file)
index 0000000..d9f8ff5
--- /dev/null
@@ -0,0 +1,47 @@
+import { Component, Input, OnInit } from '@angular/core';
+
+import * as _ from 'lodash';
+
+import { RgwDaemonService } from '../services/rgw-daemon.service';
+
+@Component({
+  selector: 'cd-rgw-daemon-details',
+  templateUrl: './rgw-daemon-details.component.html',
+  styleUrls: ['./rgw-daemon-details.component.scss']
+})
+export class RgwDaemonDetailsComponent implements OnInit {
+
+  private metadata: Array<object> = [];
+  private serviceId = '';
+
+  @Input() selected?: Array<any> = [];
+
+  constructor(private rgwDaemonService: RgwDaemonService) { }
+
+  ngOnInit() {
+    this.getMetaData();
+  }
+
+  private getMetaData() {
+    if (this.selected.length < 1) {
+      return;
+    }
+
+    // Get the service id of the first selected row.
+    this.serviceId = this.selected[0].id;
+
+    this.rgwDaemonService.get(this.serviceId)
+      .then((resp) => {
+        const metadata = [];
+        const keys = _.keys(resp['rgw_metadata']);
+        keys.sort();
+        _.map(keys, (key) => {
+          metadata.push({
+            'key': key,
+            'value': resp['rgw_metadata'][key]
+          });
+        });
+        this.metadata = metadata;
+      });
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.html
new file mode 100644 (file)
index 0000000..77814b7
--- /dev/null
@@ -0,0 +1,11 @@
+<nav aria-label="breadcrumb">
+  <ol class="breadcrumb">
+    <li class="breadcrumb-item">Object Gateway</li>
+  </ol>
+</nav>
+<cd-table [data]="daemons"
+          [columns]="columns"
+          [detailsComponent]="detailsComponent"
+          (fetchData)="getDaemonList()"
+          [beforeShowDetails]="beforeShowDetails">
+</cd-table>
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.scss b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.spec.ts
new file mode 100644 (file)
index 0000000..cc76da0
--- /dev/null
@@ -0,0 +1,35 @@
+import { HttpClientModule } from '@angular/common/http';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ComponentsModule } from '../../../shared/components/components.module';
+import { RgwDaemonService } from '../services/rgw-daemon.service';
+import { RgwDaemonListComponent } from './rgw-daemon-list.component';
+
+describe('RgwDaemonListComponent', () => {
+  let component: RgwDaemonListComponent;
+  let fixture: ComponentFixture<RgwDaemonListComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ RgwDaemonListComponent ],
+      imports: [
+        ComponentsModule,
+        HttpClientTestingModule,
+        HttpClientModule
+      ],
+      providers: [ RgwDaemonService ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwDaemonListComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw-daemon-list/rgw-daemon-list.component.ts
new file mode 100644 (file)
index 0000000..35b8e90
--- /dev/null
@@ -0,0 +1,54 @@
+import { Component, OnInit } from '@angular/core';
+
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
+import { CephShortVersionPipe } from '../../../shared/pipes/ceph-short-version.pipe';
+import { RgwDaemonService } from '../services/rgw-daemon.service';
+
+@Component({
+  selector: 'cd-rgw-daemon-list',
+  templateUrl: './rgw-daemon-list.component.html',
+  styleUrls: ['./rgw-daemon-list.component.scss']
+})
+export class RgwDaemonListComponent implements OnInit {
+
+  private columns: Array<CdTableColumn> = [];
+  private daemons: Array<object> = [];
+
+  detailsComponent = 'RgwDaemonDetailsComponent';
+
+  constructor(private rgwDaemonService: RgwDaemonService) {
+    this.columns = [
+      {
+        name: 'ID',
+        prop: 'id',
+        width: 100
+      },
+      {
+        name: 'Hostname',
+        prop: 'server_hostname',
+        width: 100
+      },
+      {
+        name: 'Version',
+        prop: 'version',
+        width: 50,
+        pipe: new CephShortVersionPipe()
+      }
+    ];
+  }
+
+  ngOnInit() {
+    this.getDaemonList();
+  }
+
+  getDaemonList() {
+    this.rgwDaemonService.list()
+      .then((resp) => {
+        this.daemons = resp;
+      });
+  }
+
+  beforeShowDetails(selected: Array<object>) {
+    return selected.length === 1;
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/rgw.module.ts
new file mode 100644 (file)
index 0000000..a888940
--- /dev/null
@@ -0,0 +1,34 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { TabsModule } from 'ngx-bootstrap/tabs';
+
+import { SharedModule } from '../../shared/shared.module';
+import { PerformanceCounterModule } from '../performance-counter/performance-counter.module';
+import { RgwDaemonDetailsComponent } from './rgw-daemon-details/rgw-daemon-details.component';
+import { RgwDaemonListComponent } from './rgw-daemon-list/rgw-daemon-list.component';
+import { RgwDaemonService } from './services/rgw-daemon.service';
+
+@NgModule({
+  entryComponents: [
+    RgwDaemonDetailsComponent
+  ],
+  imports: [
+    CommonModule,
+    SharedModule,
+    PerformanceCounterModule,
+    TabsModule.forRoot()
+  ],
+  exports: [
+    RgwDaemonListComponent,
+    RgwDaemonDetailsComponent
+  ],
+  declarations: [
+    RgwDaemonListComponent,
+    RgwDaemonDetailsComponent
+  ],
+  providers: [
+    RgwDaemonService
+  ]
+})
+export class RgwModule { }
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/services/rgw-daemon.service.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/services/rgw-daemon.service.spec.ts
new file mode 100644 (file)
index 0000000..691cc78
--- /dev/null
@@ -0,0 +1,21 @@
+import { HttpClientModule } from '@angular/common/http';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { inject, TestBed } from '@angular/core/testing';
+
+import { RgwDaemonService } from './rgw-daemon.service';
+
+describe('RgwDaemonService', () => {
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [RgwDaemonService],
+      imports: [HttpClientTestingModule, HttpClientModule]
+    });
+  });
+
+  it(
+    'should be created',
+    inject([RgwDaemonService], (service: RgwDaemonService) => {
+      expect(service).toBeTruthy();
+    })
+  );
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/services/rgw-daemon.service.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/rgw/services/rgw-daemon.service.ts
new file mode 100644 (file)
index 0000000..728452b
--- /dev/null
@@ -0,0 +1,26 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable()
+export class RgwDaemonService {
+
+  private url = '/api/rgw/daemon';
+
+  constructor(private http: HttpClient) { }
+
+  list() {
+    return this.http.get(this.url)
+      .toPromise()
+      .then((resp: any) => {
+        return resp;
+      });
+  }
+
+  get(id: string) {
+    return this.http.get(`${this.url}/${id}`)
+      .toPromise()
+      .then((resp: any) => {
+        return resp;
+      });
+  }
+}
index 2d3a261ea638d5043702a7f32036a437b03aa432..98489f2d25068a516eabccc70e75a7bbfeec9dbf 100644 (file)
           </li>
         </ul>
       </li>
+      <li routerLinkActive="active"
+          class="tc_menuitem tc_menuitem_rgw">
+        <a i18n
+           routerLink="/rgw">Object Gateway
+        </a>
+      </li>
       <!--<li class="dropdown tc_menuitem tc_menuitem_ceph_rgw">
         <a href=""
            class="dropdown-toggle"
-           data-toggle="dropdown"><ng-container i18n>Object Gateway</ng-container> <span class="caret"></span>
+           data-toggle="dropdown">
+          <ng-container i18n>Object Gateway</ng-container>
+          <span class="caret"></span>
         </a>
-        <ul class="dropdown-menu">
+        <ul *dropdownMenu
+            class="dropdown-menu">
           <li routerLinkActive="active"
-              class="tc_submenuitem tc_submenuitem_ceph_rgw_users">
+              class="tc_submenuitem tc_submenuitem_rgw_users">
             <a i18n
-               routerLink="/ceph-rgw-users">Users
+               class="dropdown-item"
+               routerLink="/rgw-users">Users
             </a>
           </li>
           <li routerLinkActive="active"
-              class="tc_submenuitem tc_submenuitem_ceph_rgw_buckets">
+              class="tc_submenuitem tc_submenuitem_rgw_buckets">
             <a i18n
-               routerLink="/ceph-rgw-buckets">Buckets
+               class="dropdown-item"
+               routerLink="/rgw-buckets">Buckets
             </a>
           </li>
         </ul>
index 0f57c48e6a6482b427cbc8a21e1d763dfef80790..2bb80eee64ef4ba92328618b2879c1e6f5e1f0c3 100644 (file)
@@ -1,16 +1,15 @@
-import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
-import { FormsModule } from '@angular/forms';
 
-import { NgxDatatableModule } from '@swimlane/ngx-datatable';
-
-import { TableDetailsDirective } from './table/table-details.directive';
-import { TableComponent } from './table/table.component';
+import { DataTableModule } from './datatable/datatable.module';
 
 @NgModule({
-  entryComponents: [],
-  imports: [CommonModule, NgxDatatableModule, FormsModule],
-  declarations: [TableComponent, TableDetailsDirective],
-  exports: [TableComponent, NgxDatatableModule]
+  imports: [
+    DataTableModule
+  ],
+  declarations: [],
+  providers: [],
+  exports: [
+    DataTableModule
+  ]
 })
 export class ComponentsModule {}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/datatable.module.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/datatable.module.ts
new file mode 100644 (file)
index 0000000..f0c6f75
--- /dev/null
@@ -0,0 +1,30 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+import { NgxDatatableModule } from '@swimlane/ngx-datatable';
+
+import { PipesModule } from '../../pipes/pipes.module';
+import { TableDetailsDirective } from './table-details.directive';
+import { TableKeyValueComponent } from './table-key-value/table-key-value.component';
+import { TableComponent } from './table/table.component';
+
+@NgModule({
+  imports: [
+    CommonModule,
+    NgxDatatableModule,
+    FormsModule,
+    PipesModule
+  ],
+  declarations: [
+    TableComponent,
+    TableDetailsDirective,
+    TableKeyValueComponent
+  ],
+  exports: [
+    TableComponent,
+    NgxDatatableModule,
+    TableKeyValueComponent
+  ]
+})
+export class DataTableModule { }
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-details.directive.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-details.directive.spec.ts
new file mode 100644 (file)
index 0000000..b3e2684
--- /dev/null
@@ -0,0 +1,8 @@
+import { TableDetailsDirective } from './table-details.directive';
+
+describe('TableDetailsDirective', () => {
+  it('should create an instance', () => {
+    const directive = new TableDetailsDirective(null);
+    expect(directive).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-details.directive.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-details.directive.ts
new file mode 100644 (file)
index 0000000..5c529dc
--- /dev/null
@@ -0,0 +1,11 @@
+import { Directive, Input, ViewContainerRef } from '@angular/core';
+
+@Directive({
+  selector: '[cdTableDetails]'
+})
+export class TableDetailsDirective {
+  @Input() selected?: any[];
+
+  constructor(public viewContainerRef: ViewContainerRef) { }
+
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.html
new file mode 100644 (file)
index 0000000..ad5346f
--- /dev/null
@@ -0,0 +1,8 @@
+<cd-table [data]="data"
+          [columns]="columns"
+          columnMode="flex"
+          [toolHeader]="false"
+          [header]="false"
+          [footer]="false"
+          [limit]="0">
+</cd-table>
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.scss b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.spec.ts
new file mode 100644 (file)
index 0000000..a116866
--- /dev/null
@@ -0,0 +1,30 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+
+import { NgxDatatableModule } from '@swimlane/ngx-datatable';
+
+import { TableComponent } from '../table/table.component';
+import { TableKeyValueComponent } from './table-key-value.component';
+
+describe('TableKeyValueComponent', () => {
+  let component: TableKeyValueComponent;
+  let fixture: ComponentFixture<TableKeyValueComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ TableComponent, TableKeyValueComponent ],
+      imports: [ FormsModule, NgxDatatableModule ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(TableKeyValueComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table-key-value/table-key-value.component.ts
new file mode 100644 (file)
index 0000000..1b641a7
--- /dev/null
@@ -0,0 +1,57 @@
+import { Component, Input, OnInit } from '@angular/core';
+
+import { CellTemplate } from '../../../enum/cell-template.enum';
+import { CdTableColumn } from '../../../models/cd-table-column';
+
+/**
+ * Display the given data in a 2 column data table. The left column
+ * shows the 'key' attribute, the right column the 'value' attribute.
+ * The data table has the following characteristics:
+ * - No header and footer is displayed
+ * - The relation of the width for the columns 'key' and 'value' is 1:3
+ * - The 'key' column is displayed in bold text
+ */
+@Component({
+  selector: 'cd-table-key-value',
+  templateUrl: './table-key-value.component.html',
+  styleUrls: ['./table-key-value.component.scss']
+})
+export class TableKeyValueComponent implements OnInit {
+
+  private columns: Array<CdTableColumn> = [];
+
+  /**
+   * An array of objects to be displayed in the data table.
+   */
+  @Input() data: Array<object> = [];
+
+  /**
+   * The name of the attribute to be displayed as key.
+   * Defaults to 'key'.
+   * @type {string}
+   */
+  @Input() key = 'key';
+
+  /**
+   * The name of the attribute to be displayed as value.
+   * Defaults to 'value'.
+   * @type {string}
+   */
+  @Input() value = 'value';
+
+  constructor() { }
+
+  ngOnInit() {
+    this.columns = [
+      {
+        prop: this.key,
+        flexGrow: 1,
+        cellTransformation: CellTemplate.bold
+      },
+      {
+        prop: this.value,
+        flexGrow: 3
+      }
+    ];
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.html
new file mode 100644 (file)
index 0000000..f51aa6f
--- /dev/null
@@ -0,0 +1,76 @@
+<div class="dataTables_wrapper">
+  <div class="dataTables_header clearfix"
+       *ngIf="toolHeader">
+    <!-- actions -->
+    <div class="oadatatableactions">
+        <ng-content select="table-actions"></ng-content>
+    </div>
+    <!-- end actions -->
+
+    <!-- search -->
+    <div class="input-group">
+      <span class="input-group-addon">
+        <i class="glyphicon glyphicon-search"></i>
+      </span>
+      <input class="form-control"
+             type="text"
+             [(ngModel)]="search"
+             (keyup)='updateFilter($event)'>
+      <span class="input-group-btn">
+        <button type="button"
+                class="btn btn-default clear-input tc_clearInputBtn"
+                (click)="updateFilter()">
+          <i class="icon-prepend fa fa-remove"></i>
+        </button>
+      </span>
+    </div>
+    <!-- end search -->
+
+    <!-- pagination limit -->
+    <div class="input-group dataTables_paginate">
+      <input class="form-control"
+             type="number"
+             min="1"
+             max="9999"
+             [value]="limit"
+             (click)="setLimit($event)"
+             (keyup)="setLimit($event)"
+             (blur)="setLimit($event)">
+    </div>
+    <!-- end pagination limit-->
+
+    <!-- refresh button -->
+    <div class="widget-toolbar tc_refreshBtn">
+      <a (click)="reloadData()">
+        <i class="fa fa-lg fa-refresh"></i>
+      </a>
+    </div>
+    <!-- end refresh button -->
+  </div>
+  <ngx-datatable #table
+                 class="bootstrap oadatatable"
+                 [cssClasses]="paginationClasses"
+                 [selectionType]="selectionType"
+                 [selected]="selected"
+                 (select)="toggleExpandRow()"
+                 [columns]="columns"
+                 [columnMode]="columnMode"
+                 [rows]="rows"
+                 [rowClass]="getRowClass()"
+                 [headerHeight]="header ? 'auto' : 0"
+                 [footerHeight]="footer ? 'auto' : 0"
+                 [limit]="limit > 0 ? limit : undefined"
+                 [loadingIndicator]="true"
+                 [rowHeight]="'auto'">
+    <!-- Row Detail Template -->
+    <ngx-datatable-row-detail (toggle)="updateDetailView()">
+    </ngx-datatable-row-detail>
+  </ngx-datatable>
+</div>
+<ng-template cdTableDetails></ng-template>
+<!-- cell templates that can be accessed from outside -->
+<ng-template #tableCellBoldTpl
+             let-row="row"
+             let-value="value">
+  <strong>{{ value }}</strong>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.scss b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.scss
new file mode 100644 (file)
index 0000000..c628988
--- /dev/null
@@ -0,0 +1,176 @@
+@import '../../../../../defaults';
+
+.dataTables_wrapper {
+  margin-bottom: 25px;
+  .separator {
+    height: 30px;
+    border-left: 1px solid rgba(0,0,0,.09);
+    padding-left: 5px;
+    margin-left: 5px;
+    display: inline-block;
+    vertical-align: middle;
+  }
+  .widget-toolbar {
+    display: inline-block;
+    float: right;
+    width: auto;
+    height: 30px;
+    line-height: 28px;
+    position: relative;
+    border-left: 1px solid rgba(0,0,0,.09);
+    cursor: pointer;
+    padding: 0 8px;
+    text-align: center;
+  }
+  .dropdown-menu {
+    white-space: nowrap;
+    & > li {
+      cursor: pointer;
+      & > label {
+        width: 100%;
+        margin-bottom: 0;
+        padding-left: 20px;
+        padding-right: 20px;
+        cursor: pointer;
+        &:hover {
+          background-color: #f5f5f5;
+        }
+        & > input {
+          cursor: pointer;
+        }
+      }
+    }
+  }
+  th.oadatatablecheckbox {
+    width: 16px;
+  }
+  .dataTables_length>input {
+    line-height: 25px;
+    text-align: right;
+  }
+}
+.dataTables_header {
+  background-color: #f6f6f6;
+  border: 1px solid #d1d1d1;
+  border-bottom: none;
+  padding: 5px;
+  position: relative;
+  .oadatatableactions {
+    display: inline-block;
+  }
+  .input-group {
+    float: right;
+    border-left: 1px solid rgba(0,0,0,.09);
+    padding-left: 8px;
+    width: 40%;
+    max-width: 350px;
+    .form-control {
+      height: 30px;
+    }
+    .clear-input {
+      height: 30px;
+      i {
+        vertical-align: text-top;
+      }
+    }
+  }
+  .input-group.dataTables_paginate {
+    width: 8%;
+  }
+}
+
+::ng-deep .oadatatable {
+  border: $border-color;
+  margin-bottom: 0;
+  max-width: none!important;
+  .datatable-header {
+    background-clip: padding-box;
+    background-color: #f9f9f9;
+    background-image: -webkit-linear-gradient(top,#fafafa 0,#ededed 100%);
+    background-image: -o-linear-gradient(top,#fafafa 0,#ededed 100%);
+    background-image: linear-gradient(to bottom,#fafafa 0,#ededed 100%);
+    background-repeat: repeat-x;
+    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffafafa', endColorstr='#ffededed', GradientType=0);
+    .sort-asc, .sort-desc {
+      color: $oa-color-blue;
+    }
+    .datatable-header-cell{
+      @include table-cell;
+      text-align: left;
+      font-weight: bold;
+      .datatable-header-cell-label {
+        &:after {
+          font-family: FontAwesome;
+          font-weight: 400;
+          height: 9px;
+          left: 10px;
+          line-height: 12px;
+          position: relative;
+          vertical-align: baseline;
+          width: 12px;
+        }
+      }
+      &.sortable {
+        .datatable-header-cell-label:after {
+          content: " \f0dc";
+        }
+        &.sort-active {
+          &.sort-asc .datatable-header-cell-label:after {
+            content: " \f160";
+          }
+          &.sort-desc .datatable-header-cell-label:after {
+            content: " \f161";
+          }
+        }
+      }
+      &:first-child {
+        border-left: none;
+      }
+    }
+  }
+  .datatable-body {
+    .datatable-body-row {
+      &.datatable-row-even {
+        background-color: #ffffff;
+      }
+      &.datatable-row-odd {
+        background-color: #f6f6f6;
+      }
+      &.active, &.active:hover {
+        background-color: $bg-color-light-blue;
+      }
+      .datatable-body-cell{
+        @include table-cell;
+        &:first-child {
+          border-left: none;
+        }
+        .datatable-body-cell-label {
+          display: block;
+        }
+      }
+    }
+  }
+  .datatable-footer {
+    .selected-count, .page-count {
+      font-style: italic;
+      padding-left: 5px;
+    }
+    .datatable-pager .pager {
+      margin-right: 5px;
+      .pages {
+        & > a, & > span {
+          display: inline-block;
+          padding: 5px 10px;
+          margin-bottom: 5px;
+          border: none;
+        }
+        a:hover {
+          background-color: $oa-color-light-blue;
+        }
+        &.active > a {
+          background-color: $bg-color-light-blue;
+        }
+      }
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.spec.ts
new file mode 100644 (file)
index 0000000..07a1825
--- /dev/null
@@ -0,0 +1,88 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+
+import { NgxDatatableModule, TableColumn } from '@swimlane/ngx-datatable';
+
+import { TableComponent } from './table.component';
+
+describe('TableComponent', () => {
+  let component: TableComponent;
+  let fixture: ComponentFixture<TableComponent>;
+  const columns: TableColumn[] = [];
+  const createFakeData = (n) => {
+    const data = [];
+    for (let i = 0; i < n; i++) {
+      data.push({
+        a: i,
+        b: i * i,
+        c: -(i % 10)
+      });
+    }
+    return data;
+  };
+
+  beforeEach(
+    async(() => {
+      TestBed.configureTestingModule({
+        declarations: [TableComponent],
+        imports: [NgxDatatableModule, FormsModule]
+      }).compileComponents();
+    })
+  );
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(TableComponent);
+    component = fixture.componentInstance;
+  });
+
+  beforeEach(() => {
+    component.data = createFakeData(100);
+    component.useData();
+    component.columns = [
+      {prop: 'a'},
+      {prop: 'b'},
+      {prop: 'c'}
+    ];
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should have rows', () => {
+    expect(component.data.length).toBe(100);
+    expect(component.rows.length).toBe(component.data.length);
+  });
+
+  it('should have an int in setLimit parsing a string', () => {
+    expect(component.limit).toBe(10);
+    expect(component.limit).toEqual(jasmine.any(Number));
+
+    const e = {target: {value: '1'}};
+    component.setLimit(e);
+    expect(component.limit).toBe(1);
+    expect(component.limit).toEqual(jasmine.any(Number));
+    e.target.value = '-20';
+    component.setLimit(e);
+    expect(component.limit).toBe(1);
+  });
+
+  it('should search for 13', () => {
+    component.search = '13';
+    expect(component.rows.length).toBe(100);
+    component.updateFilter(true);
+    expect(component.rows[0].a).toBe(13);
+    expect(component.rows[1].b).toBe(1369);
+    expect(component.rows[2].b).toBe(3136);
+    expect(component.rows.length).toBe(3);
+  });
+
+  it('should restore full table after search', () => {
+    component.search = '13';
+    expect(component.rows.length).toBe(100);
+    component.updateFilter(true);
+    expect(component.rows.length).toBe(3);
+    component.updateFilter();
+    expect(component.rows.length).toBe(100);
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/datatable/table/table.component.ts
new file mode 100644 (file)
index 0000000..d2c522e
--- /dev/null
@@ -0,0 +1,175 @@
+import {
+  AfterContentChecked,
+  Component,
+  ComponentFactoryResolver,
+  EventEmitter,
+  Input,
+  OnChanges,
+  OnInit,
+  Output,
+  TemplateRef,
+  Type,
+  ViewChild
+} from '@angular/core';
+
+import { DatatableComponent } from '@swimlane/ngx-datatable';
+import * as _ from 'lodash';
+
+import { CdTableColumn } from '../../../models/cd-table-column';
+import { TableDetailsDirective } from '../table-details.directive';
+
+@Component({
+  selector: 'cd-table',
+  templateUrl: './table.component.html',
+  styleUrls: ['./table.component.scss']
+})
+export class TableComponent implements AfterContentChecked, OnInit, OnChanges {
+  @ViewChild(DatatableComponent) table: DatatableComponent;
+  @ViewChild(TableDetailsDirective) detailTemplate: TableDetailsDirective;
+  @ViewChild('tableCellBoldTpl') tableCellBoldTpl: TemplateRef<any>;
+
+  // This is the array with the items to be shown.
+  @Input() data: any[];
+  // Each item -> { prop: 'attribute name', name: 'display name' }
+  @Input() columns: CdTableColumn[];
+  // Method used for setting column widths.
+  @Input() columnMode ?= 'force';
+  // Name of the component e.g. 'TableDetailsComponent'
+  @Input() detailsComponent?: string;
+  // Display the tool header, including reload button, pagination and search fields?
+  @Input() toolHeader ?= true;
+  // Display the table header?
+  @Input() header ?= true;
+  // Display the table footer?
+  @Input() footer ?= true;
+  // Page size to show. Set to 0 to show unlimited number of rows.
+  @Input() limit ?= 10;
+  // An optional function that is called before the details page is show.
+  // The current selection is passed as function argument. To do not display
+  // the details page, return false.
+  @Input() beforeShowDetails: Function;
+  // Should be the function that will update the input data.
+  @Output() fetchData = new EventEmitter();
+
+  cellTemplates: {
+    [key: string]: TemplateRef<any>
+  } = {};
+  selectionType: string = undefined;
+  search = '';
+  rows = [];
+  selected = [];
+  paginationClasses = {
+    pagerLeftArrow: 'i fa fa-angle-double-left',
+    pagerRightArrow: 'i fa fa-angle-double-right',
+    pagerPrevious: 'i fa fa-angle-left',
+    pagerNext: 'i fa fa-angle-right'
+  };
+
+  // Internal variable to check if it is necessary to recalculate the
+  // table columns after the browser window has been resized.
+  private currentWidth: number;
+
+  constructor(private componentFactoryResolver: ComponentFactoryResolver) { }
+
+  ngOnInit() {
+    this._addTemplates();
+    this.columns.map((column) => {
+      if (column.cellTransformation) {
+        column.cellTemplate = this.cellTemplates[column.cellTransformation];
+      }
+      return column;
+    });
+    this.reloadData();
+    if (this.detailsComponent) {
+      this.selectionType = 'multi';
+    }
+  }
+
+  ngAfterContentChecked() {
+    // If the data table is not visible, e.g. another tab is active, and the
+    // browser window gets resized, the table and its columns won't get resized
+    // automatically if the tab gets visible again.
+    // https://github.com/swimlane/ngx-datatable/issues/193
+    // https://github.com/swimlane/ngx-datatable/issues/193#issuecomment-329144543
+    if (this.table && this.table.element.clientWidth !== this.currentWidth) {
+      this.currentWidth = this.table.element.clientWidth;
+      // Force the redrawing of the table.
+      window.dispatchEvent(new Event('resize'));
+    }
+  }
+
+  _addTemplates () {
+    this.cellTemplates.bold = this.tableCellBoldTpl;
+  }
+
+  ngOnChanges(changes) {
+    this.useData();
+  }
+
+  setLimit(e) {
+    const value = parseInt(e.target.value, 10);
+    if (value > 0) {
+      this.limit = value;
+    }
+  }
+
+  reloadData() {
+    this.fetchData.emit();
+  }
+
+  useData() {
+    this.rows = [...this.data];
+  }
+
+  toggleExpandRow() {
+    if (this.selected.length > 0) {
+      this.table.rowDetail.toggleExpandRow(this.selected[0]);
+    } else {
+      this.detailTemplate.viewContainerRef.clear();
+    }
+  }
+
+  updateDetailView() {
+    if (!this.detailsComponent) {
+      return;
+    }
+    if (_.isFunction(this.beforeShowDetails)) {
+      if (!this.beforeShowDetails(this.selected)) {
+        return;
+      }
+    }
+    const factories = Array.from(this.componentFactoryResolver['_factories'].keys());
+    const factoryClass = <Type<any>>factories.find((x: any) => x.name === this.detailsComponent);
+    this.detailTemplate.viewContainerRef.clear();
+    const cmpRef = this.detailTemplate.viewContainerRef.createComponent(
+      this.componentFactoryResolver.resolveComponentFactory(factoryClass)
+    );
+    cmpRef.instance.selected = this.selected;
+  }
+
+  updateFilter(event?) {
+    if (!event) {
+      this.search = '';
+    }
+    const val = this.search.toLowerCase();
+    const columns = this.columns;
+    // update the rows
+    this.rows = this.data.filter(function (d) {
+      return columns.filter((c) => {
+        return (typeof d[c.prop] === 'string' || typeof d[c.prop] === 'number')
+          && (d[c.prop] + '').toLowerCase().indexOf(val) !== -1;
+      }).length > 0;
+    });
+    // Whenever the filter changes, always go back to the first page
+    this.table.offset = 0;
+  }
+
+  getRowClass() {
+    // Return the function used to populate a row's CSS classes.
+    return () => {
+      return {
+        'clickable': !_.isUndefined(this.detailsComponent)
+      };
+    };
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.spec.ts
deleted file mode 100644 (file)
index b3e2684..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-import { TableDetailsDirective } from './table-details.directive';
-
-describe('TableDetailsDirective', () => {
-  it('should create an instance', () => {
-    const directive = new TableDetailsDirective(null);
-    expect(directive).toBeTruthy();
-  });
-});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table-details.directive.ts
deleted file mode 100644 (file)
index 5c529dc..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-import { Directive, Input, ViewContainerRef } from '@angular/core';
-
-@Directive({
-  selector: '[cdTableDetails]'
-})
-export class TableDetailsDirective {
-  @Input() selected?: any[];
-
-  constructor(public viewContainerRef: ViewContainerRef) { }
-
-}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.html
deleted file mode 100644 (file)
index 30e1e42..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-<div class="dataTables_wrapper">
-  <div class="dataTables_header clearfix"
-       *ngIf="toolHeader">
-    <!-- actions -->
-    <div class="oadatatableactions">
-        <ng-content select="table-actions"></ng-content>
-    </div>
-    <!-- end actions -->
-
-    <!-- search -->
-    <div class="input-group">
-      <span class="input-group-addon">
-        <i class="glyphicon glyphicon-search"></i>
-      </span>
-      <input class="form-control"
-             type="text"
-             [(ngModel)]="search"
-             (keyup)='updateFilter($event)'>
-      <span class="input-group-btn">
-        <button type="button"
-                class="btn btn-default clear-input tc_clearInputBtn"
-                (click)="updateFilter()">
-          <i class="icon-prepend fa fa-remove"></i>
-        </button>
-      </span>
-    </div>
-    <!-- end search -->
-
-    <!-- pagination limit -->
-    <div class="input-group dataTables_paginate">
-      <input class="form-control"
-             type="number"
-             min="1"
-             max="9999"
-             [value]="limit"
-             (click)="setLimit($event)"
-             (keyup)="setLimit($event)"
-             (blur)="setLimit($event)">
-    </div>
-    <!-- end pagination limit-->
-
-    <!-- refresh button -->
-    <div class="widget-toolbar tc_refreshBtn">
-      <a (click)="reloadData()">
-        <i class="fa fa-lg fa-refresh"></i>
-      </a>
-    </div>
-    <!-- end refresh button -->
-  </div>
-  <ngx-datatable #table
-                 class="bootstrap oadatatable"
-                 [cssClasses]="paginationClasses"
-                 [selectionType]="selectable"
-                 [selected]="selected"
-                 (select)="toggleExpandRow()"
-                 [columns]="columns"
-                 [columnMode]="columnMode"
-                 [rows]="rows"
-                 [headerHeight]="header ? 'auto' : 0"
-                 [footerHeight]="footer ? 'auto' : 0"
-                 [limit]="limit"
-                 [loadingIndicator]="true"
-                 [rowHeight]="'auto'">
-    <!-- Row Detail Template -->
-    <ngx-datatable-row-detail (toggle)="updateDetailView()">
-    </ngx-datatable-row-detail>
-  </ngx-datatable>
-</div>
-<ng-template cdTableDetails></ng-template>
-<!-- cell templates that can be accessed from outside -->
-<ng-template #bold
-             let-row="row"
-             let-value="value">
-  <strong>{{ value }}</strong>
-</ng-template>
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.scss b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.scss
deleted file mode 100644 (file)
index 95861c8..0000000
+++ /dev/null
@@ -1,176 +0,0 @@
-@import '../../../../defaults';
-
-.dataTables_wrapper {
-  margin-bottom: 25px;
-  .separator {
-    height: 30px;
-    border-left: 1px solid rgba(0,0,0,.09);
-    padding-left: 5px;
-    margin-left: 5px;
-    display: inline-block;
-    vertical-align: middle;
-  }
-  .widget-toolbar {
-    display: inline-block;
-    float: right;
-    width: auto;
-    height: 30px;
-    line-height: 28px;
-    position: relative;
-    border-left: 1px solid rgba(0,0,0,.09);
-    cursor: pointer;
-    padding: 0 8px;
-    text-align: center;
-  }
-  .dropdown-menu {
-    white-space: nowrap;
-    & > li {
-      cursor: pointer;
-      & > label {
-        width: 100%;
-        margin-bottom: 0;
-        padding-left: 20px;
-        padding-right: 20px;
-        cursor: pointer;
-        &:hover {
-          background-color: #f5f5f5;
-        }
-        & > input {
-          cursor: pointer;
-        }
-      }
-    }
-  }
-  th.oadatatablecheckbox {
-    width: 16px;
-  }
-  .dataTables_length>input {
-    line-height: 25px;
-    text-align: right;
-  }
-}
-.dataTables_header {
-  background-color: #f6f6f6;
-  border: 1px solid #d1d1d1;
-  border-bottom: none;
-  padding: 5px;
-  position: relative;
-  .oadatatableactions {
-    display: inline-block;
-  }
-  .input-group {
-    float: right;
-    border-left: 1px solid rgba(0,0,0,.09);
-    padding-left: 8px;
-    width: 40%;
-    max-width: 350px;
-    .form-control {
-      height: 30px;
-    }
-    .clear-input {
-      height: 30px;
-      i {
-        vertical-align: text-top;
-      }
-    }
-  }
-  .input-group.dataTables_paginate {
-    width: 8%;
-  }
-}
-
-::ng-deep .oadatatable {
-  border: $border-color;
-  margin-bottom: 0;
-  max-width: none!important;
-  .datatable-header {
-    background-clip: padding-box;
-    background-color: #f9f9f9;
-    background-image: -webkit-linear-gradient(top,#fafafa 0,#ededed 100%);
-    background-image: -o-linear-gradient(top,#fafafa 0,#ededed 100%);
-    background-image: linear-gradient(to bottom,#fafafa 0,#ededed 100%);
-    background-repeat: repeat-x;
-    filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffafafa', endColorstr='#ffededed', GradientType=0);
-    .sort-asc, .sort-desc {
-      color: $oa-color-blue;
-    }
-    .datatable-header-cell{
-      @include table-cell;
-      text-align: left;
-      font-weight: bold;
-      .datatable-header-cell-label {
-        &:after {
-          font-family: FontAwesome;
-          font-weight: 400;
-          height: 9px;
-          left: 10px;
-          line-height: 12px;
-          position: relative;
-          vertical-align: baseline;
-          width: 12px;
-        }
-      }
-      &.sortable {
-        .datatable-header-cell-label:after {
-          content: " \f0dc";
-        }
-        &.sort-active {
-          &.sort-asc .datatable-header-cell-label:after {
-            content: " \f160";
-          }
-          &.sort-desc .datatable-header-cell-label:after {
-            content: " \f161";
-          }
-        }
-      }
-      &:first-child {
-        border-left: none;
-      }
-    }
-  }
-  .datatable-body {
-    .datatable-body-row {
-      &.datatable-row-even {
-        background-color: #ffffff;
-      }
-      &.datatable-row-odd {
-        background-color: #f6f6f6;
-      }
-      &.active, &.active:hover {
-        background-color: $bg-color-light-blue;
-      }
-      .datatable-body-cell{
-        @include table-cell;
-        &:first-child {
-          border-left: none;
-        }
-        .datatable-body-cell-label {
-          display: block;
-        }
-      }
-    }
-  }
-  .datatable-footer {
-    .selected-count, .page-count {
-      font-style: italic;
-      padding-left: 5px;
-    }
-    .datatable-pager .pager {
-      margin-right: 5px;
-      .pages {
-        & > a, & > span {
-          display: inline-block;
-          padding: 5px 10px;
-          margin-bottom: 5px;
-          border: none;
-        }
-        a:hover {
-          background-color: $oa-color-light-blue;
-        }
-        &.active > a {
-          background-color: $bg-color-light-blue;
-        }
-      }
-    }
-  }
-}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.spec.ts
deleted file mode 100644 (file)
index 07a1825..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
-import { FormsModule } from '@angular/forms';
-
-import { NgxDatatableModule, TableColumn } from '@swimlane/ngx-datatable';
-
-import { TableComponent } from './table.component';
-
-describe('TableComponent', () => {
-  let component: TableComponent;
-  let fixture: ComponentFixture<TableComponent>;
-  const columns: TableColumn[] = [];
-  const createFakeData = (n) => {
-    const data = [];
-    for (let i = 0; i < n; i++) {
-      data.push({
-        a: i,
-        b: i * i,
-        c: -(i % 10)
-      });
-    }
-    return data;
-  };
-
-  beforeEach(
-    async(() => {
-      TestBed.configureTestingModule({
-        declarations: [TableComponent],
-        imports: [NgxDatatableModule, FormsModule]
-      }).compileComponents();
-    })
-  );
-
-  beforeEach(() => {
-    fixture = TestBed.createComponent(TableComponent);
-    component = fixture.componentInstance;
-  });
-
-  beforeEach(() => {
-    component.data = createFakeData(100);
-    component.useData();
-    component.columns = [
-      {prop: 'a'},
-      {prop: 'b'},
-      {prop: 'c'}
-    ];
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-
-  it('should have rows', () => {
-    expect(component.data.length).toBe(100);
-    expect(component.rows.length).toBe(component.data.length);
-  });
-
-  it('should have an int in setLimit parsing a string', () => {
-    expect(component.limit).toBe(10);
-    expect(component.limit).toEqual(jasmine.any(Number));
-
-    const e = {target: {value: '1'}};
-    component.setLimit(e);
-    expect(component.limit).toBe(1);
-    expect(component.limit).toEqual(jasmine.any(Number));
-    e.target.value = '-20';
-    component.setLimit(e);
-    expect(component.limit).toBe(1);
-  });
-
-  it('should search for 13', () => {
-    component.search = '13';
-    expect(component.rows.length).toBe(100);
-    component.updateFilter(true);
-    expect(component.rows[0].a).toBe(13);
-    expect(component.rows[1].b).toBe(1369);
-    expect(component.rows[2].b).toBe(3136);
-    expect(component.rows.length).toBe(3);
-  });
-
-  it('should restore full table after search', () => {
-    component.search = '13';
-    expect(component.rows.length).toBe(100);
-    component.updateFilter(true);
-    expect(component.rows.length).toBe(3);
-    component.updateFilter();
-    expect(component.rows.length).toBe(100);
-  });
-});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/components/table/table.component.ts
deleted file mode 100644 (file)
index c0df5b1..0000000
+++ /dev/null
@@ -1,136 +0,0 @@
-import {
-  Component,
-  ComponentFactoryResolver,
-  EventEmitter,
-  Input,
-  OnChanges,
-  OnInit,
-  Output,
-  TemplateRef,
-  Type,
-  ViewChild
-} from '@angular/core';
-
-import { DatatableComponent } from '@swimlane/ngx-datatable';
-
-import { CdTableColumn } from '../../models/cd-table-column';
-import { TableDetailsDirective } from './table-details.directive';
-
-@Component({
-  selector: 'cd-table',
-  templateUrl: './table.component.html',
-  styleUrls: ['./table.component.scss']
-})
-export class TableComponent implements OnInit, OnChanges {
-  @ViewChild(DatatableComponent) table: DatatableComponent;
-  @ViewChild(TableDetailsDirective) detailTemplate: TableDetailsDirective;
-
-  // This is the array with the items to be shown
-  @Input() data: any[];
-  // Each item -> { prop: 'attribute name', name: 'display name' }
-  @Input() columns: CdTableColumn[];
-  // Method used for setting column widths.
-  @Input() columnMode ?= 'force';
-  // Name of the component fe 'TableDetailsComponent'
-  @Input() detailsComponent?: string;
-  // Display the tool header, including reload button, pagination and search fields?
-  @Input() toolHeader ?= true;
-  // Display the table header?
-  @Input() header ?= true;
-  // Display the table footer?
-  @Input() footer ?= true;
-  // Should be the function that will update the input data
-  @Output() fetchData = new EventEmitter();
-
-  @ViewChild('bold') bold: TemplateRef<any>;
-  cellTemplates: {
-    [key: string]: TemplateRef<any>
-  } = {};
-
-  selectable: String = undefined;
-  search = '';
-  rows = [];
-  selected = [];
-  paginationClasses = {
-    pagerLeftArrow: 'i fa fa-angle-double-left',
-    pagerRightArrow: 'i fa fa-angle-double-right',
-    pagerPrevious: 'i fa fa-angle-left',
-    pagerNext: 'i fa fa-angle-right'
-  };
-  limit = 10;
-
-  constructor(private componentFactoryResolver: ComponentFactoryResolver) { }
-
-  ngOnInit() {
-    this._addTemplates();
-    this.columns.map((column) => {
-      if (column.cellTransformation) {
-        column.cellTemplate = this.cellTemplates[column.cellTransformation];
-      }
-      return column;
-    });
-    this.reloadData();
-    if (this.detailsComponent) {
-      this.selectable = 'multi';
-    }
-  }
-
-  _addTemplates () {
-    this.cellTemplates.bold = this.bold;
-  }
-
-  ngOnChanges(changes) {
-    this.useData();
-  }
-
-  setLimit(e) {
-    const value = parseInt(e.target.value, 10);
-    if (value > 0) {
-      this.limit = value;
-    }
-  }
-
-  reloadData() {
-    this.fetchData.emit();
-  }
-
-  useData() {
-    this.rows = [...this.data];
-  }
-
-  toggleExpandRow() {
-    if (this.selected.length > 0) {
-      this.table.rowDetail.toggleExpandRow(this.selected[0]);
-    }
-  }
-
-  updateDetailView() {
-    if (!this.detailsComponent) {
-      return;
-    }
-    const factories = Array.from(this.componentFactoryResolver['_factories'].keys());
-    const factoryClass = <Type<any>>factories.find((x: any) => x.name === this.detailsComponent);
-    this.detailTemplate.viewContainerRef.clear();
-    const cmpRef = this.detailTemplate.viewContainerRef.createComponent(
-      this.componentFactoryResolver.resolveComponentFactory(factoryClass)
-    );
-    cmpRef.instance.selected = this.selected;
-  }
-
-  updateFilter(event?) {
-    if (!event) {
-      this.search = '';
-    }
-    const val = this.search.toLowerCase();
-    const columns = this.columns;
-    // update the rows
-    this.rows = this.data.filter(function (d) {
-      return columns.filter((c) => {
-        return (typeof d[c.prop] === 'string' || typeof d[c.prop] === 'number')
-          && (d[c.prop] + '').toLowerCase().indexOf(val) !== -1;
-      }).length > 0;
-    });
-    // Whenever the filter changes, always go back to the first page
-    this.table.offset = 0;
-  }
-}
index e4cbc3b344da7c822121a13d1233b38af245fb55..2a6a863ffd325031b78c261dbc6feffd73a49ab7 100644 (file)
@@ -21,6 +21,7 @@ import { ServicesModule } from './services/services.module';
   ],
   exports: [
     PipesModule,
+    ComponentsModule,
     ServicesModule,
     PasswordButtonDirective
   ],
index 0d8665ea8436df38a127bf6873e70241baaf2d1f..adc791ee76c245c5be784ad7215de20a763222b6 100755 (executable)
@@ -102,12 +102,13 @@ option {
 .strikethrough {
   text-decoration: line-through;
 }
-
 .italic {
   font-style: italic;
 }
-
-text-right {
+.bold {
+  font-weight: bold;
+}
+.text-right {
   text-align: right;
 }
 
index 6568c204cd92b3de51b542b3daadbc5145f935e7..8e4e79aeb481c3d0410c71c61fef2512ce24b149 100644 (file)
@@ -121,7 +121,6 @@ class Module(MgrModule):
         cherrypy.engine.start()
         NotificationQueue.start_queue()
         logger.info('Waiting for engine...')
-        self.log.info('Waiting for engine...')
         cherrypy.engine.block()
         if 'COVERAGE_ENABLED' in os.environ:
             _cov.stop()
diff --git a/src/pybind/mgr/dashboard_v2/tests/test_perf_counters.py b/src/pybind/mgr/dashboard_v2/tests/test_perf_counters.py
new file mode 100644 (file)
index 0000000..94458e0
--- /dev/null
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+from .helper import ControllerTestCase, authenticate
+
+
+class PerfCountersControllerTest(ControllerTestCase):
+
+    @authenticate
+    def test_perf_counters_list(self):
+        data = self._get('/api/perf_counters')
+        self.assertStatus(200)
+
+        self.assertIsInstance(data, dict)
+        self.assertIn('mon.a', data)
+        self.assertIn('mon.b', data)
+        self.assertIn('mon.c', data)
+        self.assertIn('osd.0', data)
+        self.assertIn('osd.1', data)
+        self.assertIn('osd.2', data)
+
+    @authenticate
+    def test_perf_counters_mon_a_get(self):
+        data = self._get('/api/perf_counters/mon/a')
+        self.assertStatus(200)
+
+        self.assertIsInstance(data, dict)
+        self.assertEqual('mon', data['service']['type'])
+        self.assertEqual('a', data['service']['id'])
+        self.assertIsInstance(data['counters'], list)
+        self.assertGreater(len(data['counters']), 0)
+        counter = data['counters'][0]
+        self.assertIsInstance(counter, dict)
+        self.assertIn('description', counter)
+        self.assertIn('name', counter)
+        self.assertIn('unit', counter)
+        self.assertIn('value', counter)