]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard_v2: add health page and its dependencies
authorTiago Melo <tmelo@suse.com>
Thu, 1 Feb 2018 11:17:43 +0000 (11:17 +0000)
committerRicardo Dias <rdias@suse.com>
Mon, 5 Mar 2018 13:07:07 +0000 (13:07 +0000)
Signed-off-by: Tiago Melo <tmelo@suse.com>
52 files changed:
src/pybind/mgr/dashboard_v2/controllers/dashboard.py [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/.angular-cli.json
src/pybind/mgr/dashboard_v2/frontend/package.json
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/app.module.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/ceph.module.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard.module.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/log-color.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/log-color.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mds-summary.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mds-summary.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mgr-summary.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mgr-summary.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mon-summary.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mon-summary.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/osd-summary.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/osd-summary.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status-style.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status-style.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/core/auth/login/login.component.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/models/credentials.model.ts [deleted file]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/models/credentials.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless-binary.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless-binary.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/health-color.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/health-color.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/auth.service.ts
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/formatter.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/formatter.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/services.module.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/top-level.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/top-level.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard_v2/frontend/src/app/shared/shared.module.ts
src/pybind/mgr/dashboard_v2/tests/test_dashboard.py [new file with mode: 0644]

diff --git a/src/pybind/mgr/dashboard_v2/controllers/dashboard.py b/src/pybind/mgr/dashboard_v2/controllers/dashboard.py
new file mode 100644 (file)
index 0000000..ea1c06d
--- /dev/null
@@ -0,0 +1,176 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import collections
+from collections import defaultdict
+import json
+import time
+
+import cherrypy
+from mgr_module import CommandResult
+
+from ..tools import ApiController, AuthRequired, BaseController, NotificationQueue
+
+
+LOG_BUFFER_SIZE = 30
+
+
+@ApiController('dashboard')
+@AuthRequired()
+class Dashboard(BaseController):
+    def __init__(self):
+        super(Dashboard, self).__init__()
+
+        self._log_initialized = False
+
+        self.log_buffer = collections.deque(maxlen=LOG_BUFFER_SIZE)
+        self.audit_buffer = collections.deque(maxlen=LOG_BUFFER_SIZE)
+
+    def append_log(self, log_struct):
+        if log_struct['channel'] == "audit":
+            self.audit_buffer.appendleft(log_struct)
+        else:
+            self.log_buffer.appendleft(log_struct)
+
+    def load_buffer(self, buf, channel_name):
+        result = CommandResult("")
+        self.mgr.send_command(result, "mon", "", json.dumps({
+            "prefix": "log last",
+            "format": "json",
+            "channel": channel_name,
+            "num": LOG_BUFFER_SIZE
+        }), "")
+        r, outb, outs = result.wait()
+        if r != 0:
+            # Oh well. We won't let this stop us though.
+            self.log.error("Error fetching log history (r={0}, \"{1}\")".format(
+                r, outs))
+        else:
+            try:
+                lines = json.loads(outb)
+            except ValueError:
+                self.log.error("Error decoding log history")
+            else:
+                for l in lines:
+                    buf.appendleft(l)
+
+    @cherrypy.expose
+    @cherrypy.tools.json_out()
+    def toplevel(self):
+        fsmap = self.mgr.get("fs_map")
+
+        filesystems = [
+            {
+                "id": f['id'],
+                "name": f['mdsmap']['fs_name']
+            }
+            for f in fsmap['filesystems']
+        ]
+
+        return {
+            'health_status': self.health_data()['status'],
+            'filesystems': filesystems,
+        }
+
+    # pylint: disable=R0914
+    @cherrypy.expose
+    @cherrypy.tools.json_out()
+    def health(self):
+        if not self._log_initialized:
+            self._log_initialized = True
+
+            self.load_buffer(self.log_buffer, "cluster")
+            self.load_buffer(self.audit_buffer, "audit")
+
+            NotificationQueue.register(self.append_log, 'clog')
+
+        # Fuse osdmap with pg_summary to get description of pools
+        # including their PG states
+
+        osd_map = self.osd_map()
+
+        pg_summary = self.mgr.get("pg_summary")
+
+        pools = []
+
+        pool_stats = defaultdict(lambda: defaultdict(
+            lambda: collections.deque(maxlen=10)))
+
+        df = self.mgr.get("df")
+        pool_stats_dict = dict([(p['id'], p['stats']) for p in df['pools']])
+        now = time.time()
+        for pool_id, stats in pool_stats_dict.items():
+            for stat_name, stat_val in stats.items():
+                pool_stats[pool_id][stat_name].appendleft((now, stat_val))
+
+        for pool in osd_map['pools']:
+            pool['pg_status'] = pg_summary['by_pool'][pool['pool'].__str__()]
+            stats = pool_stats[pool['pool']]
+            s = {}
+
+            def get_rate(series):
+                if len(series) >= 2:
+                    return (float(series[0][1]) - float(series[1][1])) / \
+                        (float(series[0][0]) - float(series[1][0]))
+                return 0
+
+            for stat_name, stat_series in stats.items():
+                s[stat_name] = {
+                    'latest': stat_series[0][1],
+                    'rate': get_rate(stat_series),
+                    'series': [i for i in stat_series]
+                }
+            pool['stats'] = s
+            pools.append(pool)
+
+        # Not needed, skip the effort of transmitting this
+        # to UI
+        del osd_map['pg_temp']
+
+        df['stats']['total_objects'] = sum(
+            [p['stats']['objects'] for p in df['pools']])
+
+        return {
+            "health": self.health_data(),
+            "mon_status": self.mon_status(),
+            "fs_map": self.mgr.get('fs_map'),
+            "osd_map": osd_map,
+            "clog": list(self.log_buffer),
+            "audit_log": list(self.audit_buffer),
+            "pools": pools,
+            "mgr_map": self.mgr.get("mgr_map"),
+            "df": df
+        }
+
+    def mon_status(self):
+        mon_status_data = self.mgr.get("mon_status")
+        return json.loads(mon_status_data['json'])
+
+    def osd_map(self):
+        osd_map = self.mgr.get("osd_map")
+
+        assert osd_map is not None
+
+        osd_map['tree'] = self.mgr.get("osd_map_tree")
+        osd_map['crush'] = self.mgr.get("osd_map_crush")
+        osd_map['crush_map_text'] = self.mgr.get("osd_map_crush_map_text")
+        osd_map['osd_metadata'] = self.mgr.get("osd_metadata")
+
+        return osd_map
+
+    def health_data(self):
+        health_data = self.mgr.get("health")
+        health = json.loads(health_data['json'])
+
+        # Transform the `checks` dict into a list for the convenience
+        # of rendering from javascript.
+        checks = []
+        for k, v in health['checks'].items():
+            v['type'] = k
+            checks.append(v)
+
+        checks = sorted(checks, key=lambda c: c['severity'])
+
+        health['checks'] = checks
+
+        return health
index c623375b8c6c946935aa47bcb03af50d372e9c1a..6ffd7b7d6e8fe35ebb1299adc0617d462d092118 100644 (file)
@@ -25,7 +25,9 @@
         "../node_modules/awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css",
         "styles.scss"
       ],
-      "scripts": [],
+      "scripts": [
+        "../node_modules/chart.js/dist/Chart.bundle.js"
+      ],
       "environmentSource": "environments/environment.ts",
       "environments": {
         "dev": "environments/environment.ts",
index 2e0d90b17583a0fb55b77a8f94a53acb66617126..c622bfba44bdb45c8c45ea9f6f70c0eae9f136aa 100644 (file)
     "@angular/platform-browser": "^5.0.0",
     "@angular/platform-browser-dynamic": "^5.0.0",
     "@angular/router": "^5.0.0",
+    "@types/lodash": "^4.14.95",
     "awesome-bootstrap-checkbox": "0.3.7",
     "@swimlane/ngx-datatable": "^11.1.7",
     "bootstrap": "^3.3.7",
+    "chart.js": "^2.7.1",
     "core-js": "^2.4.1",
     "font-awesome": "4.7.0",
+    "lodash": "^4.17.4",
+    "ng2-charts": "^1.6.0",
     "ng2-toastr": "4.1.2",
     "ngx-bootstrap": "^2.0.1",
     "rxjs": "^5.5.2",
index 628d5b54c2ded10bdcc164b62127402d4998b95a..237867c83ec597024585eaa9827b205d390a79fd 100644 (file)
@@ -2,17 +2,23 @@ import { NgModule } from '@angular/core';
 import { RouterModule, Routes } from '@angular/router';
 
 import { HostsComponent } from './ceph/cluster/hosts/hosts.component';
+import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component';
 import { LoginComponent } from './core/auth/login/login.component';
 import { AuthGuardService } from './shared/services/auth-guard.service';
 
 const routes: Routes = [
-  { path: '', redirectTo: 'hosts', pathMatch: 'full' },
+  { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
+  {
+    path: 'dashboard',
+    component: DashboardComponent,
+    canActivate: [AuthGuardService]
+  },
   { path: 'login', component: LoginComponent },
   { path: 'hosts', component: HostsComponent, canActivate: [AuthGuardService] }
 ];
 
 @NgModule({
-  imports: [RouterModule.forRoot(routes, {useHash: true})],
+  imports: [RouterModule.forRoot(routes, { useHash: true })],
   exports: [RouterModule]
 })
-export class AppRoutingModule { }
+export class AppRoutingModule {}
index 88ddd4562d50c3125e2b84b6bf7d17171c883cc4..e7925a0743f2da3cf3d2e85636eb733fa0960fb5 100644 (file)
@@ -9,30 +9,18 @@ import { CoreModule } from './core/core.module';
 import { SharedModule } from './shared/shared.module';
 
 describe('AppComponent', () => {
-  beforeEach(async(() => {
-    TestBed.configureTestingModule({
-      imports: [
-        RouterTestingModule,
-        CoreModule,
-        SharedModule,
-        ToastModule.forRoot(),
-        ClusterModule
-      ],
-      declarations: [
-        AppComponent
-      ],
-    }).compileComponents();
-  }));
-
-  it('should create the app', async(() => {
-    const fixture = TestBed.createComponent(AppComponent);
-    const app = fixture.debugElement.componentInstance;
-    expect(app).toBeTruthy();
-  }));
-
-  it(`should have as title 'oa'`, async(() => {
-    const fixture = TestBed.createComponent(AppComponent);
-    const app = fixture.debugElement.componentInstance;
-    expect(app.title).toEqual('cd');
-  }));
+  beforeEach(
+    async(() => {
+      TestBed.configureTestingModule({
+        imports: [
+          RouterTestingModule,
+          CoreModule,
+          SharedModule,
+          ToastModule.forRoot(),
+          ClusterModule
+        ],
+        declarations: [AppComponent]
+      }).compileComponents();
+    })
+  );
 });
index 99186e5394fab01e3a0d53de98cde5d8295addef..3211acdc61841eebf6a0a7c491961da8a4f94d67 100644 (file)
@@ -31,7 +31,9 @@ export class CustomOption extends ToastOptions {
     HttpClientModule,
     CoreModule,
     SharedModule,
-    CephModule
+    CephModule,
+    HttpClientModule,
+    BrowserAnimationsModule
   ],
   exports: [SharedModule],
   providers: [
index af7e4662721bf6686844cfe0c24d995b07ff73af..00f9548728cbbdb10b38ce86bb540a32fc8070e4 100644 (file)
@@ -2,11 +2,13 @@ import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
 
 import { ClusterModule } from './cluster/cluster.module';
+import { DashboardModule } from './dashboard/dashboard.module';
 
 @NgModule({
   imports: [
     CommonModule,
-    ClusterModule
+    ClusterModule,
+    DashboardModule
   ],
   declarations: []
 })
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard.module.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard.module.ts
new file mode 100644 (file)
index 0000000..d2f70a8
--- /dev/null
@@ -0,0 +1,34 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { ChartsModule } from 'ng2-charts';
+import { TabsModule } from 'ngx-bootstrap/tabs';
+
+import { SharedModule } from '../../shared/shared.module';
+import { DashboardService } from './dashboard.service';
+import { DashboardComponent } from './dashboard/dashboard.component';
+import { HealthComponent } from './health/health.component';
+import { LogColorPipe } from './log-color.pipe';
+import { MdsSummaryPipe } from './mds-summary.pipe';
+import { MgrSummaryPipe } from './mgr-summary.pipe';
+import { MonSummaryPipe } from './mon-summary.pipe';
+import { OsdSummaryPipe } from './osd-summary.pipe';
+import { PgStatusStylePipe } from './pg-status-style.pipe';
+import { PgStatusPipe } from './pg-status.pipe';
+
+@NgModule({
+  imports: [CommonModule, TabsModule.forRoot(), SharedModule, ChartsModule],
+  declarations: [
+    HealthComponent,
+    DashboardComponent,
+    MonSummaryPipe,
+    OsdSummaryPipe,
+    LogColorPipe,
+    MgrSummaryPipe,
+    PgStatusPipe,
+    MdsSummaryPipe,
+    PgStatusStylePipe
+  ],
+  providers: [DashboardService]
+})
+export class DashboardModule {}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard.service.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard.service.spec.ts
new file mode 100644 (file)
index 0000000..bf061e9
--- /dev/null
@@ -0,0 +1,23 @@
+import { HttpClientModule } from '@angular/common/http';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { inject, TestBed } from '@angular/core/testing';
+
+import { appendFile } from 'fs';
+
+import { DashboardService } from './dashboard.service';
+
+describe('DashboardService', () => {
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [DashboardService],
+      imports: [HttpClientTestingModule, HttpClientModule]
+    });
+  });
+
+  it(
+    'should be created',
+    inject([DashboardService], (service: DashboardService) => {
+      expect(service).toBeTruthy();
+    })
+  );
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard.service.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard.service.ts
new file mode 100644 (file)
index 0000000..807983c
--- /dev/null
@@ -0,0 +1,11 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+@Injectable()
+export class DashboardService {
+  constructor(private http: HttpClient) {}
+
+  getHealth() {
+    return this.http.get('/api/dashboard/health');
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.html
new file mode 100644 (file)
index 0000000..10b3713
--- /dev/null
@@ -0,0 +1,10 @@
+<div>
+  <tabset *ngIf="hasGrafana">
+    <tab heading="Health">
+      <cd-health></cd-health>
+    </tab>
+    <tab heading="Statistics">
+    </tab>
+  </tabset>
+  <cd-health *ngIf="!hasGrafana"></cd-health>
+</div>
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.scss b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.scss
new file mode 100644 (file)
index 0000000..04eee2d
--- /dev/null
@@ -0,0 +1,3 @@
+div {
+  padding-top: 20px;
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.spec.ts
new file mode 100644 (file)
index 0000000..80500c0
--- /dev/null
@@ -0,0 +1,25 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DashboardComponent } from './dashboard.component';
+
+describe('DashboardComponent', () => {
+  let component: DashboardComponent;
+  let fixture: ComponentFixture<DashboardComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ DashboardComponent ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(DashboardComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  // it('should create', () => {
+  //   expect(component).toBeTruthy();
+  // });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/dashboard/dashboard.component.ts
new file mode 100644 (file)
index 0000000..fc676c7
--- /dev/null
@@ -0,0 +1,16 @@
+import { Component, OnInit } from '@angular/core';
+
+@Component({
+  selector: 'cd-dashboard',
+  templateUrl: './dashboard.component.html',
+  styleUrls: ['./dashboard.component.scss']
+})
+export class DashboardComponent implements OnInit {
+  hasGrafana = false; // TODO: Temporary var, remove when grafana is implemented
+
+  constructor() { }
+
+  ngOnInit() {
+  }
+
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.html b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.html
new file mode 100644 (file)
index 0000000..533d493
--- /dev/null
@@ -0,0 +1,219 @@
+<div *ngIf="contentData">
+  <div class="row">
+    <!-- HEALTH -->
+    <div class="col-md-6">
+      <div class="well">
+        <fieldset>
+          <legend i18n>Health</legend>
+          <ng-container i18n>Overall status:</ng-container>
+          <span [ngStyle]="contentData.health.status | healthColor">{{ contentData.health.status }}</span>
+          <ul>
+            <li *ngFor="let check of contentData.health.checks">
+              <span [ngStyle]="check.severity | healthColor">{{ check.type }}</span>: {{ check.summary.message }}
+            </li>
+          </ul>
+        </fieldset>
+      </div>
+    </div>
+
+    <div class="col-md-6">
+      <!--STATS -->
+      <div class="row">
+        <div class="col-md-6">
+          <div class="well">
+            <div class="media">
+              <div class="media-left">
+                <i class="fa fa-database fa-fw"></i>
+              </div>
+              <div class="media-body">
+                <span class="media-heading"
+                      i18n="ceph monitors">Monitors</span>
+                <span class="media-text">{{ contentData.mon_status | monSummary }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="col-md-6">
+          <div class="well">
+            <div class="media">
+              <div class="media-left">
+                <i class="fa fa-hdd-o fa-fw"></i>
+              </div>
+              <div class="media-body">
+                <span class="media-heading"
+                      i18n="ceph OSDs">OSDs</span>
+                <span class="media-text">{{ contentData.osd_map | osdSummary }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-md-6">
+          <div class="well">
+            <div class="media">
+              <div class="media-left">
+                <i class="fa fa-folder fa-fw"></i>
+              </div>
+              <div class="media-body">
+                <span class="media-heading"
+                      i18n>Metadata servers</span>
+                <span class="media-text">{{ contentData.fs_map | mdsSummary }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="col-md-6">
+          <div class="well">
+            <div class="media">
+              <div class="media-left">
+                <i class="fa fa-cog fa-fw"></i>
+              </div>
+              <div class="media-body">
+                <span class="media-heading"
+                      i18n>Manager daemons</span>
+                <span class="media-text">{{ contentData.mgr_map | mgrSummary }}</span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="row">
+    <!-- USAGE -->
+    <div class="col-md-6">
+      <div class="well">
+        <fieldset class="usage">
+          <legend i18n>Usage</legend>
+
+          <table class="ceph-chartbox">
+            <tr>
+              <td>
+                <span style="font-size: 45px;">{{ contentData.df.stats.total_objects | dimless }}</span>
+              </td>
+              <td>
+                <div class="center-block pie"
+                     *ngIf="rawUsage.dataset">
+                  <canvas baseChart
+                          id="raw_usage_chart"
+                          [datasets]="rawUsage.dataset"
+                          [chartType]="rawUsage.chartType"
+                          [options]="rawUsage.options"
+                          [labels]="rawUsage.labels"
+                          [colors]="rawUsage.colors"
+                          width="120"
+                          height="120"></canvas>
+                </div>
+              </td>
+              <td>
+                <div class="center-block pie"
+                     *ngIf="poolUsage.dataset">
+                  <canvas baseChart
+                          id="pool_usage_chart"
+                          [datasets]="poolUsage.dataset"
+                          [chartType]="poolUsage.chartType"
+                          [options]="poolUsage.options"
+                          [labels]="poolUsage.labels"
+                          [colors]="poolUsage.colors"
+                          width="120"
+                          height="120"></canvas>
+                </div>
+              </td>
+            </tr>
+            <tr>
+              <td i18n>Objects</td>
+              <td>
+                <ng-container i18n>Raw capacity</ng-container>
+                <br>
+                <ng-container i18n="disk used">({{ contentData.df.stats.total_used_bytes | dimlessBinary }} used)</ng-container>
+              </td>
+              <td i18n>Usage by pool</td>
+            </tr>
+          </table>
+        </fieldset>
+      </div>
+    </div>
+
+    <div class="col-md-6">
+      <div class="well">
+        <fieldset>
+          <legend i18n>Pools</legend>
+          <table class="table table-condensed">
+            <thead>
+              <tr>
+                <th i18n>Name</th>
+                <th i18n>PG status</th>
+                <th i18n>Usage</th>
+                <th colspan="2"
+                    i18n>Read</th>
+                <th colspan="2"
+                    i18n>Write</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr *ngFor="let pool of contentData.pools">
+                <td>{{ pool.pool_name }}</td>
+                <td [ngStyle]="pool.pg_status | pgStatusStyle">
+                  {{ pool.pg_status | pgStatus }}
+                </td>
+                <td>
+                  {{ pool.stats.bytes_used.latest | dimlessBinary }} / {{ pool.stats.max_avail.latest | dimlessBinary }}
+                </td>
+                <td>
+                  {{ pool.stats.rd_bytes.rate | dimless }}
+                </td>
+                <td>
+                  {{ pool.stats.rd.rate | dimless }} ops
+                </td>
+                <td>
+                  {{ pool.stats.wr_bytes.rate | dimless }}
+                </td>
+                <td>
+                  {{ pool.stats.wr.rate | dimless }} ops
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </fieldset>
+      </div>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-md-12">
+      <!-- LOGS -->
+      <div class="well">
+        <fieldset>
+          <legend i18n>Logs</legend>
+
+          <tabset>
+            <tab heading="Cluster log"
+                 i18n-heading>
+              <span *ngFor="let line of contentData.clog">
+                {{ line.stamp }}&nbsp;{{ line.priority }}&nbsp;
+                <span [ngStyle]="line | logColor">
+                  {{ line.message }}
+                  <br>
+                </span>
+              </span>
+            </tab>
+            <tab heading="Audit log"
+                 i18n-heading>
+              <span *ngFor="let line of contentData.audit_log">
+                {{ line.stamp }}&nbsp;{{ line.priority }}&nbsp;
+                <span [ngStyle]="line | logColor">
+                  <span style="font-weight: bold;">
+                    {{ line.message }}
+                  </span>
+                  <br>
+                </span>
+              </span>
+            </tab>
+          </tabset>
+        </fieldset>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.scss b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.scss
new file mode 100644 (file)
index 0000000..919b41d
--- /dev/null
@@ -0,0 +1,62 @@
+table.ceph-chartbox {
+  width: 100%;
+
+  td {
+    text-align: center;
+    font-weight: bold;
+  }
+}
+
+.center-block {
+  width: 120px;
+}
+
+.pie {
+  height: 120px;
+  width: 120px;
+}
+
+.media {
+  display: block;
+  min-height: 60px;
+  width: 100%;
+
+  .media-left {
+    border-top-left-radius: 2px;
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+    border-bottom-left-radius: 2px;
+    display: block;
+    float: left;
+    height: 60px;
+    width: 60px;
+    text-align: center;
+    font-size: 40px;
+    line-height: 60px;
+    padding-right: 0;
+
+    .fa {
+      font-size: 45px;
+    }
+  }
+
+  .media-body {
+    padding: 5px 10px;
+    margin-left: 60px;
+
+    .media-heading {
+      text-transform: uppercase;
+      display: block;
+      font-size: 14px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .media-text {
+      display: block;
+      font-weight: bold;
+      font-size: 18px;
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.spec.ts
new file mode 100644 (file)
index 0000000..cac806a
--- /dev/null
@@ -0,0 +1,43 @@
+import { HttpClientModule } from '@angular/common/http';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ChartsModule } from 'ng2-charts';
+import { TabsModule } from 'ngx-bootstrap/tabs';
+
+import { AppModule } from '../../../app.module';
+import { SharedModule } from '../../../shared/shared.module';
+import { DashboardService } from '../dashboard.service';
+import { LogColorPipe } from '../log-color.pipe';
+import { MdsSummaryPipe } from '../mds-summary.pipe';
+import { MgrSummaryPipe } from '../mgr-summary.pipe';
+import { MonSummaryPipe } from '../mon-summary.pipe';
+import { OsdSummaryPipe } from '../osd-summary.pipe';
+import { PgStatusStylePipe } from '../pg-status-style.pipe';
+import { PgStatusPipe } from '../pg-status.pipe';
+import { HealthComponent } from './health.component';
+
+describe('HealthComponent', () => {
+  let component: HealthComponent;
+  let fixture: ComponentFixture<HealthComponent>;
+  const dashboardServiceStub = {
+    getHealth() {
+      return {};
+    }
+  };
+  beforeEach(
+    async(() => {
+      TestBed.configureTestingModule({
+        providers: [
+          { provide: DashboardService, useValue: dashboardServiceStub }
+        ],
+        imports: [AppModule]
+      }).compileComponents();
+    })
+  );
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(HealthComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/health/health.component.ts
new file mode 100644 (file)
index 0000000..0a065c1
--- /dev/null
@@ -0,0 +1,210 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+
+import * as Chart from 'chart.js';
+import * as _ from 'lodash';
+
+import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import { DashboardService } from '../dashboard.service';
+
+@Component({
+  selector: 'cd-health',
+  templateUrl: './health.component.html',
+  styleUrls: ['./health.component.scss']
+})
+export class HealthComponent implements OnInit, OnDestroy {
+  contentData: any;
+  interval: any;
+  poolUsage: any = {
+    chartType: 'doughnut'
+  };
+  rawUsage: any = {
+    chartType: 'doughnut',
+    center_text: 0
+  };
+
+  constructor(
+    private dimlessBinary: DimlessBinaryPipe,
+    private dashboardService: DashboardService
+  ) {}
+
+  ngOnInit() {
+    // An extension to Chart.js to enable rendering some
+    // text in the middle of a doughnut
+    Chart.pluginService.register({
+      beforeDraw: function(chart) {
+        if (!chart.options.center_text) {
+          return;
+        }
+        const width = chart.chart.width,
+          height = chart.chart.height,
+          ctx = chart.chart.ctx;
+
+        ctx.restore();
+        const fontSize = (height / 114).toFixed(2);
+        ctx.font = fontSize + 'em sans-serif';
+        ctx.textBaseline = 'middle';
+
+        const text = chart.options.center_text,
+          textX = Math.round((width - ctx.measureText(text).width) / 2),
+          textY = height / 2;
+
+        ctx.fillText(text, textX, textY);
+        ctx.save();
+      }
+    });
+
+    this.getInfo();
+    this.interval = setInterval(() => {
+      this.getInfo();
+    }, 5000);
+  }
+
+  ngOnDestroy() {
+    clearInterval(this.interval);
+  }
+
+  getInfo() {
+    this.dashboardService.getHealth().subscribe((data: any) => {
+      this.contentData = data;
+      this.draw_usage_charts();
+    });
+  }
+
+  draw_usage_charts() {
+    let rawUsageChartColor;
+    const rawUsageText =
+      Math.round(
+        100 *
+          (this.contentData.df.stats.total_used_bytes /
+            this.contentData.df.stats.total_bytes)
+      ) + '%';
+    if (
+      this.contentData.df.stats.total_used_bytes /
+        this.contentData.df.stats.total_bytes >=
+      this.contentData.osd_map.full_ratio
+    ) {
+      rawUsageChartColor = '#ff0000';
+    } else if (
+      this.contentData.df.stats.total_used_bytes /
+        this.contentData.df.stats.total_bytes >=
+      this.contentData.osd_map.backfillfull_ratio
+    ) {
+      rawUsageChartColor = '#ff6600';
+    } else if (
+      this.contentData.df.stats.total_used_bytes /
+        this.contentData.df.stats.total_bytes >=
+      this.contentData.osd_map.nearfull_ratio
+    ) {
+      rawUsageChartColor = '#ffc200';
+    } else {
+      rawUsageChartColor = '#00bb00';
+    }
+
+    this.rawUsage = {
+      chartType: 'doughnut',
+      dataset: [
+        {
+          label: null,
+          borderWidth: 0,
+          data: [
+            this.contentData.df.stats.total_used_bytes,
+            this.contentData.df.stats.total_avail_bytes
+          ]
+        }
+      ],
+      options: {
+        center_text: rawUsageText,
+        responsive: true,
+        legend: { display: false },
+        animation: { duration: 0 },
+        tooltips: {
+          callbacks: {
+            label: (tooltipItem, chart) => {
+              return (
+                chart.labels[tooltipItem.index] +
+                ': ' +
+                this.dimlessBinary.transform(
+                  chart.datasets[0].data[tooltipItem.index]
+                )
+              );
+            }
+          }
+        }
+      },
+      colors: [
+        {
+          backgroundColor: [rawUsageChartColor, '#424d52'],
+          borderColor: 'transparent'
+        }
+      ],
+      labels: ['Raw Used', 'Raw Available']
+    };
+
+    const colors = [
+      '#3366CC',
+      '#109618',
+      '#990099',
+      '#3B3EAC',
+      '#0099C6',
+      '#DD4477',
+      '#66AA00',
+      '#B82E2E',
+      '#316395',
+      '#994499',
+      '#22AA99',
+      '#AAAA11',
+      '#6633CC',
+      '#E67300',
+      '#8B0707',
+      '#329262',
+      '#5574A6',
+      '#FF9900',
+      '#DC3912',
+      '#3B3EAC'
+    ];
+
+    const poolLabels = [];
+    const poolData = [];
+
+    _.each(this.contentData.df.pools, function(pool, i) {
+      poolLabels.push(pool['name']);
+      poolData.push(pool['stats']['bytes_used']);
+    });
+
+    this.poolUsage = {
+      chartType: 'doughnut',
+      dataset: [
+        {
+          label: null,
+          borderWidth: 0,
+          data: poolData
+        }
+      ],
+      options: {
+        responsive: true,
+        legend: { display: false },
+        animation: { duration: 0 },
+        tooltips: {
+          callbacks: {
+            label: (tooltipItem, chart) => {
+              return (
+                chart.labels[tooltipItem.index] +
+                ': ' +
+                this.dimlessBinary.transform(
+                  chart.datasets[0].data[tooltipItem.index]
+                )
+              );
+            }
+          }
+        }
+      },
+      colors: [
+        {
+          backgroundColor: colors,
+          borderColor: 'transparent'
+        }
+      ],
+      labels: poolLabels
+    };
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/log-color.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/log-color.pipe.spec.ts
new file mode 100644 (file)
index 0000000..43af68d
--- /dev/null
@@ -0,0 +1,8 @@
+import { LogColorPipe } from './log-color.pipe';
+
+describe('LogColorPipe', () => {
+  it('create an instance', () => {
+    const pipe = new LogColorPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/log-color.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/log-color.pipe.ts
new file mode 100644 (file)
index 0000000..eb60ddb
--- /dev/null
@@ -0,0 +1,21 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'logColor'
+})
+export class LogColorPipe implements PipeTransform {
+  transform(value: any, args?: any): any {
+    if (value.priority === '[INF]') {
+      return ''; // Inherit
+    } else if (value.priority === '[WRN]') {
+      return {
+        color: '#ffa500',
+        'font-weight': 'bold'
+      };
+    } else if (value.priority === '[ERR]') {
+      return { color: '#FF2222' };
+    } else {
+      return '';
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mds-summary.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mds-summary.pipe.spec.ts
new file mode 100644 (file)
index 0000000..37883a8
--- /dev/null
@@ -0,0 +1,8 @@
+import { MdsSummaryPipe } from './mds-summary.pipe';
+
+describe('MdsSummaryPipe', () => {
+  it('create an instance', () => {
+    const pipe = new MdsSummaryPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mds-summary.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mds-summary.pipe.ts
new file mode 100644 (file)
index 0000000..9e6eeca
--- /dev/null
@@ -0,0 +1,38 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import * as _ from 'lodash';
+
+@Pipe({
+  name: 'mdsSummary'
+})
+export class MdsSummaryPipe implements PipeTransform {
+  transform(value: any, args?: any): any {
+    if (!value) {
+      return '';
+    }
+
+    let standbys = 0;
+    let active = 0;
+    let standbyReplay = 0;
+    _.each(value.standbys, (s, i) => {
+      standbys += 1;
+    });
+
+    if (value.standbys && !value.filesystems) {
+      return standbys + ', no filesystems';
+    } else if (value.filesystems.length === 0) {
+      return 'no filesystems';
+    } else {
+      _.each(value.filesystems, (fs, i) => {
+        _.each(fs.mdsmap.info, (mds, j) => {
+          if (mds.state === 'up:standby-replay') {
+            standbyReplay += 1;
+          } else {
+            active += 1;
+          }
+        });
+      });
+
+      return active + ' active, ' + (standbys + standbyReplay) + ' standby';
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mgr-summary.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mgr-summary.pipe.spec.ts
new file mode 100644 (file)
index 0000000..fdab76c
--- /dev/null
@@ -0,0 +1,8 @@
+import { MgrSummaryPipe } from './mgr-summary.pipe';
+
+describe('MgrSummaryPipe', () => {
+  it('create an instance', () => {
+    const pipe = new MgrSummaryPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mgr-summary.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mgr-summary.pipe.ts
new file mode 100644 (file)
index 0000000..cf793e6
--- /dev/null
@@ -0,0 +1,22 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import * as _ from 'lodash';
+
+@Pipe({
+  name: 'mgrSummary'
+})
+export class MgrSummaryPipe implements PipeTransform {
+  transform(value: any, args?: any): any {
+    if (!value) {
+      return '';
+    }
+
+    let result = 'active: ';
+    result += _.isUndefined(value.active_name) ? 'n/a' : value.active_name;
+
+    if (value.standbys.length) {
+      result += ', ' + value.standbys.length + ' standbys';
+    }
+
+    return result;
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mon-summary.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mon-summary.pipe.spec.ts
new file mode 100644 (file)
index 0000000..49526cf
--- /dev/null
@@ -0,0 +1,8 @@
+import { MonSummaryPipe } from './mon-summary.pipe';
+
+describe('MonSummaryPipe', () => {
+  it('create an instance', () => {
+    const pipe = new MonSummaryPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mon-summary.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/mon-summary.pipe.ts
new file mode 100644 (file)
index 0000000..6877e22
--- /dev/null
@@ -0,0 +1,18 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'monSummary'
+})
+export class MonSummaryPipe implements PipeTransform {
+  transform(value: any, args?: any): any {
+    if (!value) {
+      return '';
+    }
+
+    let result = value.monmap.mons.length.toString() + ' (quorum ';
+    result += value.quorum.join(', ');
+    result += ')';
+
+    return result;
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/osd-summary.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/osd-summary.pipe.spec.ts
new file mode 100644 (file)
index 0000000..466eec1
--- /dev/null
@@ -0,0 +1,8 @@
+import { OsdSummaryPipe } from './osd-summary.pipe';
+
+describe('OsdSummaryPipe', () => {
+  it('create an instance', () => {
+    const pipe = new OsdSummaryPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/osd-summary.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/osd-summary.pipe.ts
new file mode 100644 (file)
index 0000000..b02d976
--- /dev/null
@@ -0,0 +1,26 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import * as _ from 'lodash';
+
+@Pipe({
+  name: 'osdSummary'
+})
+export class OsdSummaryPipe implements PipeTransform {
+  transform(value: any, args?: any): any {
+    if (!value) {
+      return '';
+    }
+
+    let inCount = 0;
+    let upCount = 0;
+    _.each(value.osds, (osd, i) => {
+      if (osd.in) {
+        inCount++;
+      }
+      if (osd.up) {
+        upCount++;
+      }
+    });
+
+    return value.osds.length + ' (' + upCount + ' up, ' + inCount + ' in)';
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status-style.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status-style.pipe.spec.ts
new file mode 100644 (file)
index 0000000..67c5f10
--- /dev/null
@@ -0,0 +1,8 @@
+import { PgStatusStylePipe } from './pg-status-style.pipe';
+
+describe('PgStatusStylePipe', () => {
+  it('create an instance', () => {
+    const pipe = new PgStatusStylePipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status-style.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status-style.pipe.ts
new file mode 100644 (file)
index 0000000..4e9afab
--- /dev/null
@@ -0,0 +1,40 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import * as _ from 'lodash';
+
+@Pipe({
+  name: 'pgStatusStyle'
+})
+export class PgStatusStylePipe implements PipeTransform {
+  transform(pgStatus: any, args?: any): any {
+    let warning = false;
+    let error = false;
+
+    _.each(pgStatus, (value, state) => {
+      if (
+        state.includes('inconsistent') ||
+        state.includes('incomplete') ||
+        !state.includes('active')
+      ) {
+        error = true;
+      }
+
+      if (
+        state !== 'active+clean' &&
+        state !== 'active+clean+scrubbing' &&
+        state !== 'active+clean+scrubbing+deep'
+      ) {
+        warning = true;
+      }
+    });
+
+    if (error) {
+      return { color: '#FF0000' };
+    }
+
+    if (warning) {
+      return { color: '#FFC200' };
+    }
+
+    return { color: '#00BB00' };
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status.pipe.spec.ts
new file mode 100644 (file)
index 0000000..d7d5592
--- /dev/null
@@ -0,0 +1,8 @@
+import { PgStatusPipe } from './pg-status.pipe';
+
+describe('PgStatusPipe', () => {
+  it('create an instance', () => {
+    const pipe = new PgStatusPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/ceph/dashboard/pg-status.pipe.ts
new file mode 100644 (file)
index 0000000..5c6c7b3
--- /dev/null
@@ -0,0 +1,16 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import * as _ from 'lodash';
+
+@Pipe({
+  name: 'pgStatus'
+})
+export class PgStatusPipe implements PipeTransform {
+  transform(pgStatus: any, args?: any): any {
+    const strings = [];
+    _.each(pgStatus, (count, state) => {
+      strings.push(count + ' ' + state);
+    });
+
+    return strings.join(', ');
+  }
+}
index e68609f714f6c10051ef8404831bf206d2f31a0a..f8f46254976cc862446cc6916ff85babbb033aff 100644 (file)
@@ -3,7 +3,7 @@ import { Router } from '@angular/router';
 
 import { ToastsManager } from 'ng2-toastr';
 
-import { Credentials } from '../../../shared/models/credentials.model';
+import { Credentials } from '../../../shared/models/credentials';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { AuthService } from '../../../shared/services/auth.service';
 
index e1338e3e4ee79a8b2f7f2f55fa5b1c871487b88f..e93c304f21a4382c4348fdbebbcd3b425abd465e 100644 (file)
   <div class="collapse navbar-collapse"
        id="bs-example-navbar-collapse-1">
     <ul class="nav navbar-nav navbar-primary">
-      <!-- <li routerLinkActive="active"
+      <li routerLinkActive="active"
           class="tc_menuitem tc_menuitem_dashboard">
         <a i18n
-           routerLink="/dashboard">Dashboard
+           routerLink="/dashboard">
+          <i class="fa fa-heartbeat fa-fw"
+             [ngStyle]="topLevelData?.health_status | healthColor"></i>
+          <span>Dashboard</span>
         </a>
       </li>
-      <li routerLinkActive="active"
+      <!--
+  <li routerLinkActive="active"
           class="tc_menuitem tc_menuitem_ceph_osds">
         <a i18n
            routerLink="/cephOsds">OSDs
index 35109d2437cf65ca3bb9771a7d5314043fd071a2..102798d11b39523e13627b39dbb86938ca0ced5a 100644 (file)
@@ -1,4 +1,5 @@
 import { Component, OnInit } from '@angular/core';
+import { TopLevelService } from '../../../shared/services/top-level.service';
 
 @Component({
   selector: 'cd-navigation',
@@ -6,10 +7,13 @@ import { Component, OnInit } from '@angular/core';
   styleUrls: ['./navigation.component.scss']
 })
 export class NavigationComponent implements OnInit {
+  topLevelData: any;
 
-  constructor() { }
-
-  ngOnInit() {
+  constructor(topLevelService: TopLevelService) {
+    topLevelService.topLevelData$.subscribe(data => {
+      this.topLevelData = data;
+    });
   }
 
+  ngOnInit() {}
 }
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/models/credentials.model.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/models/credentials.model.ts
deleted file mode 100644 (file)
index b33c366..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-export class Credentials {
-  username: string;
-  password: string;
-  stay_signed_in = false;
-}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/models/credentials.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/models/credentials.ts
new file mode 100644 (file)
index 0000000..b33c366
--- /dev/null
@@ -0,0 +1,5 @@
+export class Credentials {
+  username: string;
+  password: string;
+  stay_signed_in = false;
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless-binary.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless-binary.pipe.spec.ts
new file mode 100644 (file)
index 0000000..2424ebc
--- /dev/null
@@ -0,0 +1,10 @@
+import { FormatterService } from '../services/formatter.service';
+import { DimlessBinaryPipe } from './dimless-binary.pipe';
+
+describe('DimlessBinaryPipe', () => {
+  it('create an instance', () => {
+    const formatterService = new FormatterService();
+    const pipe = new DimlessBinaryPipe(formatterService);
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless-binary.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless-binary.pipe.ts
new file mode 100644 (file)
index 0000000..92f0008
--- /dev/null
@@ -0,0 +1,20 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { FormatterService } from '../services/formatter.service';
+
+@Pipe({
+  name: 'dimlessBinary'
+})
+export class DimlessBinaryPipe implements PipeTransform {
+  constructor(private formatter: FormatterService) {}
+
+  transform(value: any, args?: any): any {
+    return this.formatter.format_number(value, 1024, [
+      'B',
+      'KiB',
+      'MiB',
+      'GiB',
+      'TiB',
+      'PiB'
+    ]);
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless.pipe.spec.ts
new file mode 100644 (file)
index 0000000..4bbfdd8
--- /dev/null
@@ -0,0 +1,10 @@
+import { FormatterService } from '../services/formatter.service';
+import { DimlessPipe } from './dimless.pipe';
+
+describe('DimlessPipe', () => {
+  it('create an instance', () => {
+    const formatterService = new FormatterService();
+    const pipe = new DimlessPipe(formatterService);
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/dimless.pipe.ts
new file mode 100644 (file)
index 0000000..5e02846
--- /dev/null
@@ -0,0 +1,20 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { FormatterService } from '../services/formatter.service';
+
+@Pipe({
+  name: 'dimless'
+})
+export class DimlessPipe implements PipeTransform {
+  constructor(private formatter: FormatterService) {}
+
+  transform(value: any, args?: any): any {
+    return this.formatter.format_number(value, 1000, [
+      ' ',
+      'k',
+      'M',
+      'G',
+      'T',
+      'P'
+    ]);
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/health-color.pipe.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/health-color.pipe.spec.ts
new file mode 100644 (file)
index 0000000..e0e44e0
--- /dev/null
@@ -0,0 +1,8 @@
+import { HealthColorPipe } from './health-color.pipe';
+
+describe('HealthColorPipe', () => {
+  it('create an instance', () => {
+    const pipe = new HealthColorPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/health-color.pipe.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/pipes/health-color.pipe.ts
new file mode 100644 (file)
index 0000000..9d82475
--- /dev/null
@@ -0,0 +1,18 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'healthColor'
+})
+export class HealthColorPipe implements PipeTransform {
+  transform(value: any, args?: any): any {
+    if (value === 'HEALTH_OK') {
+      return { color: '#00bb00' };
+    } else if (value === 'HEALTH_WARN') {
+      return { color: '#ffa500' };
+    } else if (value === 'HEALTH_ERR') {
+      return { color: '#ff0000' };
+    } else {
+      return null;
+    }
+  }
+}
index 9f9ed4077c3e562f255fbd3ba0cec0b6d43d885d..aa4d4739e9dd14e4869d0b1b0f338f4983fe61cd 100644 (file)
@@ -2,11 +2,24 @@ import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
 
 import { CephShortVersionPipe } from './ceph-short-version.pipe';
+import { DimlessBinaryPipe } from './dimless-binary.pipe';
+import { DimlessPipe } from './dimless.pipe';
+import { HealthColorPipe } from './health-color.pipe';
 
 @NgModule({
   imports: [CommonModule],
-  declarations: [CephShortVersionPipe],
-  exports: [CephShortVersionPipe],
-  providers: []
+  declarations: [
+    DimlessBinaryPipe,
+    HealthColorPipe,
+    DimlessPipe,
+    CephShortVersionPipe
+  ],
+  exports: [
+    DimlessBinaryPipe,
+    HealthColorPipe,
+    DimlessPipe,
+    CephShortVersionPipe
+  ],
+  providers: [DimlessBinaryPipe]
 })
 export class PipesModule {}
index b8d58533c0aa89fc608f1501e0991605774fbca1..d5001157fedd946605073fa74974ba5c7bbae850 100644 (file)
@@ -1,7 +1,7 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
-import { Credentials } from '../models/credentials.model';
+import { Credentials } from '../models/credentials';
 import { AuthStorageService } from './auth-storage.service';
 
 @Injectable()
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/formatter.service.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/formatter.service.spec.ts
new file mode 100644 (file)
index 0000000..f3a99b5
--- /dev/null
@@ -0,0 +1,15 @@
+import { inject, TestBed } from '@angular/core/testing';
+
+import { FormatterService } from './formatter.service';
+
+describe('FormatterService', () => {
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [FormatterService]
+    });
+  });
+
+  it('should be created', inject([FormatterService], (service: FormatterService) => {
+    expect(service).toBeTruthy();
+  }));
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/formatter.service.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/formatter.service.ts
new file mode 100644 (file)
index 0000000..e22d408
--- /dev/null
@@ -0,0 +1,51 @@
+import { Injectable } from '@angular/core';
+
+@Injectable()
+export class FormatterService {
+  constructor() {}
+
+  truncate(n, maxWidth) {
+    const stringized = n.toString();
+    const parts = stringized.split('.');
+    if (parts.length === 1) {
+      // Just an int
+      return stringized;
+    } else {
+      const fractionalDigits = maxWidth - parts[0].length - 1;
+      if (fractionalDigits <= 0) {
+        // No width available for the fractional part, drop
+        // it and the decimal point
+        return parts[0];
+      } else {
+        return stringized.substring(0, maxWidth);
+      }
+    }
+  }
+
+  format_number(n, divisor, units) {
+    const width = 4;
+    let unit = 0;
+
+    if (n == null) {
+      // People shouldn't really be passing null, but let's
+      // do something sensible instead of barfing.
+      return '-';
+    }
+
+    while (Math.floor(n / divisor ** unit).toString().length > width - 1) {
+      unit = unit + 1;
+    }
+
+    let truncatedFloat;
+    if (unit > 0) {
+      truncatedFloat = this.truncate(
+        (n / Math.pow(divisor, unit)).toString(),
+        width
+      );
+    } else {
+      truncatedFloat = this.truncate(n, width);
+    }
+
+    return truncatedFloat + units[unit];
+  }
+}
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/services.module.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/services.module.ts
new file mode 100644 (file)
index 0000000..0b54f97
--- /dev/null
@@ -0,0 +1,14 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { FormatterService } from './formatter.service';
+import { TopLevelService } from './top-level.service';
+
+@NgModule({
+  imports: [
+    CommonModule
+  ],
+  declarations: [],
+  providers: [FormatterService, TopLevelService]
+})
+export class ServicesModule { }
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/top-level.service.spec.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/top-level.service.spec.ts
new file mode 100644 (file)
index 0000000..3962cb6
--- /dev/null
@@ -0,0 +1,21 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { inject, TestBed } from '@angular/core/testing';
+
+import { SharedModule } from '../shared.module';
+import { TopLevelService } from './top-level.service';
+
+describe('TopLevelService', () => {
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [TopLevelService],
+      imports: [HttpClientTestingModule, SharedModule]
+    });
+  });
+
+  it(
+    'should be created',
+    inject([TopLevelService], (service: TopLevelService) => {
+      expect(service).toBeTruthy();
+    })
+  );
+});
diff --git a/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/top-level.service.ts b/src/pybind/mgr/dashboard_v2/frontend/src/app/shared/services/top-level.service.ts
new file mode 100644 (file)
index 0000000..cee098d
--- /dev/null
@@ -0,0 +1,32 @@
+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';
+
+@Injectable()
+export class TopLevelService {
+  // Observable sources
+  private topLevelDataSource = new Subject();
+
+  // Observable streams
+  topLevelData$ = this.topLevelDataSource.asObservable();
+
+  constructor(private http: HttpClient, private authStorageService: AuthStorageService) {
+    this.refresh();
+  }
+
+  refresh() {
+    if (this.authStorageService.isLoggedIn()) {
+      this.http.get('/api/dashboard/toplevel').subscribe(data => {
+        this.topLevelDataSource.next(data);
+      });
+    }
+
+    setTimeout(() => {
+      this.refresh();
+    }, 5000);
+  }
+}
index d81541869684d16062683e36007bf90c50432a59..ac14d9f3313157c79f432d18e87d988cbf7a3d43 100644 (file)
@@ -7,22 +7,22 @@ import { AuthGuardService } from './services/auth-guard.service';
 import { AuthStorageService } from './services/auth-storage.service';
 import { AuthService } from './services/auth.service';
 import { HostService } from './services/host.service';
+import { ServicesModule } from './services/services.module';
 
 @NgModule({
   imports: [
     CommonModule,
     PipesModule,
-    ComponentsModule
+    ComponentsModule,
+    ServicesModule
   ],
+  exports: [PipesModule, ServicesModule, PipesModule],
   declarations: [],
   providers: [
     AuthService,
     AuthStorageService,
     AuthGuardService,
     HostService
-  ],
-  exports: [
-    PipesModule
   ]
 })
-export class SharedModule { }
+export class SharedModule {}
diff --git a/src/pybind/mgr/dashboard_v2/tests/test_dashboard.py b/src/pybind/mgr/dashboard_v2/tests/test_dashboard.py
new file mode 100644 (file)
index 0000000..0c7e08c
--- /dev/null
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+from .helper import ControllerTestCase, authenticate
+
+
+class DashboardTest(ControllerTestCase):
+    @authenticate
+    def test_toplevel(self):
+        data = self._get("/api/dashboard/toplevel")
+        self.assertStatus(200)
+
+        self.assertIn('filesystems', data)
+        self.assertIn('health_status', data)
+        self.assertIsNotNone(data['filesystems'])
+        self.assertIsNotNone(data['health_status'])
+
+    @authenticate
+    def test_health(self):
+        data = self._get("/api/dashboard/health")
+        self.assertStatus(200)
+
+        self.assertIn('health', data)
+        self.assertIn('mon_status', data)
+        self.assertIn('fs_map', data)
+        self.assertIn('osd_map', data)
+        self.assertIn('clog', data)
+        self.assertIn('audit_log', data)
+        self.assertIn('pools', data)
+        self.assertIn('mgr_map', data)
+        self.assertIn('df', data)
+        self.assertIsNotNone(data['health'])
+        self.assertIsNotNone(data['mon_status'])
+        self.assertIsNotNone(data['fs_map'])
+        self.assertIsNotNone(data['osd_map'])
+        self.assertIsNotNone(data['clog'])
+        self.assertIsNotNone(data['audit_log'])
+        self.assertIsNotNone(data['pools'])
+        self.assertIsNotNone(data['mgr_map'])
+        self.assertIsNotNone(data['df'])