--- /dev/null
+# -*- 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
"../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",
"@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",
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 {}
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();
+ })
+ );
});
HttpClientModule,
CoreModule,
SharedModule,
- CephModule
+ CephModule,
+ HttpClientModule,
+ BrowserAnimationsModule
],
exports: [SharedModule],
providers: [
import { NgModule } from '@angular/core';
import { ClusterModule } from './cluster/cluster.module';
+import { DashboardModule } from './dashboard/dashboard.module';
@NgModule({
imports: [
CommonModule,
- ClusterModule
+ ClusterModule,
+ DashboardModule
],
declarations: []
})
--- /dev/null
+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 {}
--- /dev/null
+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();
+ })
+ );
+});
--- /dev/null
+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');
+ }
+}
--- /dev/null
+<div>
+ <tabset *ngIf="hasGrafana">
+ <tab heading="Health">
+ <cd-health></cd-health>
+ </tab>
+ <tab heading="Statistics">
+ </tab>
+ </tabset>
+ <cd-health *ngIf="!hasGrafana"></cd-health>
+</div>
--- /dev/null
+div {
+ padding-top: 20px;
+}
--- /dev/null
+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();
+ // });
+});
--- /dev/null
+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() {
+ }
+
+}
--- /dev/null
+<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 }} {{ line.priority }}
+ <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 }} {{ line.priority }}
+ <span [ngStyle]="line | logColor">
+ <span style="font-weight: bold;">
+ {{ line.message }}
+ </span>
+ <br>
+ </span>
+ </span>
+ </tab>
+ </tabset>
+ </fieldset>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+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;
+ }
+ }
+}
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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
+ };
+ }
+}
--- /dev/null
+import { LogColorPipe } from './log-color.pipe';
+
+describe('LogColorPipe', () => {
+ it('create an instance', () => {
+ const pipe = new LogColorPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+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 '';
+ }
+ }
+}
--- /dev/null
+import { MdsSummaryPipe } from './mds-summary.pipe';
+
+describe('MdsSummaryPipe', () => {
+ it('create an instance', () => {
+ const pipe = new MdsSummaryPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+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';
+ }
+ }
+}
--- /dev/null
+import { MgrSummaryPipe } from './mgr-summary.pipe';
+
+describe('MgrSummaryPipe', () => {
+ it('create an instance', () => {
+ const pipe = new MgrSummaryPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+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;
+ }
+}
--- /dev/null
+import { MonSummaryPipe } from './mon-summary.pipe';
+
+describe('MonSummaryPipe', () => {
+ it('create an instance', () => {
+ const pipe = new MonSummaryPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+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;
+ }
+}
--- /dev/null
+import { OsdSummaryPipe } from './osd-summary.pipe';
+
+describe('OsdSummaryPipe', () => {
+ it('create an instance', () => {
+ const pipe = new OsdSummaryPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+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)';
+ }
+}
--- /dev/null
+import { PgStatusStylePipe } from './pg-status-style.pipe';
+
+describe('PgStatusStylePipe', () => {
+ it('create an instance', () => {
+ const pipe = new PgStatusStylePipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+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' };
+ }
+}
--- /dev/null
+import { PgStatusPipe } from './pg-status.pipe';
+
+describe('PgStatusPipe', () => {
+ it('create an instance', () => {
+ const pipe = new PgStatusPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+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(', ');
+ }
+}
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';
<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
import { Component, OnInit } from '@angular/core';
+import { TopLevelService } from '../../../shared/services/top-level.service';
@Component({
selector: 'cd-navigation',
styleUrls: ['./navigation.component.scss']
})
export class NavigationComponent implements OnInit {
+ topLevelData: any;
- constructor() { }
-
- ngOnInit() {
+ constructor(topLevelService: TopLevelService) {
+ topLevelService.topLevelData$.subscribe(data => {
+ this.topLevelData = data;
+ });
}
+ ngOnInit() {}
}
+++ /dev/null
-export class Credentials {
- username: string;
- password: string;
- stay_signed_in = false;
-}
--- /dev/null
+export class Credentials {
+ username: string;
+ password: string;
+ stay_signed_in = false;
+}
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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'
+ ]);
+ }
+}
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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'
+ ]);
+ }
+}
--- /dev/null
+import { HealthColorPipe } from './health-color.pipe';
+
+describe('HealthColorPipe', () => {
+ it('create an instance', () => {
+ const pipe = new HealthColorPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+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;
+ }
+ }
+}
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 {}
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()
--- /dev/null
+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();
+ }));
+});
--- /dev/null
+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];
+ }
+}
--- /dev/null
+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 { }
--- /dev/null
+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();
+ })
+ );
+});
--- /dev/null
+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);
+ }
+}
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 {}
--- /dev/null
+# -*- 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'])