Merge pull request #50109 from rhcs-dashboard/landing-page-v3

mgr/dashboard: Landing page v3

Reviewed-by: bryanmontalvan <NOT@FOUND>
Reviewed-by: Nizamudeen A <nia@redhat.com>
This commit is contained in:
Nizamudeen A 2023-02-16 16:21:12 +05:30 committed by GitHub
commit 7e5b2542ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2433 additions and 21 deletions

View File

@ -19,3 +19,7 @@ class Cluster(RESTController):
parameters={'status': (str, 'Cluster Status')})
def singleton_set(self, status: str):
ClusterModel(status).to_db()
@RESTController.Collection('GET', 'capacity')
def get_capacity(self):
return ClusterModel.get_capacity()

View File

@ -86,6 +86,11 @@ class Prometheus(PrometheusRESTController):
def rules(self, **params):
return self.prometheus_proxy('GET', '/rules', params)
@RESTController.Collection(method='GET', path='/data')
def get_prometeus_data(self, **params):
params['query'] = params.pop('params')
return self.prometheus_proxy('GET', '/query_range', params)
@RESTController.Collection(method='GET', path='/silences')
def get_silences(self, **params):
return self.alert_proxy('GET', '/silences', params)

View File

@ -24,7 +24,8 @@ import { SilenceListComponent } from './ceph/cluster/prometheus/silence-list/sil
import { ServiceFormComponent } from './ceph/cluster/services/service-form/service-form.component';
import { ServicesComponent } from './ceph/cluster/services/services.component';
import { TelemetryComponent } from './ceph/cluster/telemetry/telemetry.component';
import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component';
import { DeprecatedDashboardComponent } from './ceph/dashboard/dashboard/dashboard.component';
import { DashboardComponent } from './ceph/new-dashboard/dashboard/dashboard.component';
import { NfsFormComponent } from './ceph/nfs/nfs-form/nfs-form.component';
import { NfsListComponent } from './ceph/nfs/nfs-list/nfs-list.component';
import { PerformanceCounterComponent } from './ceph/performance-counter/performance-counter/performance-counter.component';
@ -88,7 +89,8 @@ const routes: Routes = [
canActivate: [AuthGuardService, ChangePasswordGuardService],
canActivateChild: [AuthGuardService, ChangePasswordGuardService],
children: [
{ path: 'dashboard', component: DashboardComponent },
{ path: 'dashboard', component: DeprecatedDashboardComponent },
{ path: 'dashboard_3', component: DashboardComponent },
{ path: 'error', component: ErrorComponent },
// Cluster

View File

@ -5,6 +5,7 @@ import { SharedModule } from '../shared/shared.module';
import { CephfsModule } from './cephfs/cephfs.module';
import { ClusterModule } from './cluster/cluster.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { NewDashboardModule } from './new-dashboard/dashboard.module';
import { NfsModule } from './nfs/nfs.module';
import { PerformanceCounterModule } from './performance-counter/performance-counter.module';
@ -13,6 +14,7 @@ import { PerformanceCounterModule } from './performance-counter/performance-coun
CommonModule,
ClusterModule,
DashboardModule,
NewDashboardModule,
PerformanceCounterModule,
CephfsModule,
NfsModule,

View File

@ -9,7 +9,7 @@ import { ChartsModule } from 'ng2-charts';
import { SharedModule } from '~/app/shared/shared.module';
import { CephSharedModule } from '../shared/ceph-shared.module';
import { FeedbackComponent } from '../shared/feedback/feedback.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { DeprecatedDashboardComponent } from './dashboard/dashboard.component';
import { HealthPieComponent } from './health-pie/health-pie.component';
import { HealthComponent } from './health/health.component';
import { InfoCardComponent } from './info-card/info-card.component';
@ -34,7 +34,7 @@ import { OsdSummaryPipe } from './osd-summary.pipe';
declarations: [
HealthComponent,
DashboardComponent,
DeprecatedDashboardComponent,
MonSummaryPipe,
OsdSummaryPipe,
MgrSummaryPipe,

View File

@ -4,20 +4,20 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { configureTestBed } from '~/testing/unit-test-helper';
import { DashboardComponent } from './dashboard.component';
import { DeprecatedDashboardComponent } from './dashboard.component';
describe('DashboardComponent', () => {
let component: DashboardComponent;
let fixture: ComponentFixture<DashboardComponent>;
let component: DeprecatedDashboardComponent;
let fixture: ComponentFixture<DeprecatedDashboardComponent>;
configureTestBed({
imports: [NgbNavModule],
declarations: [DashboardComponent],
declarations: [DeprecatedDashboardComponent],
schemas: [NO_ERRORS_SCHEMA]
});
beforeEach(() => {
fixture = TestBed.createComponent(DashboardComponent);
fixture = TestBed.createComponent(DeprecatedDashboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -5,6 +5,6 @@ import { Component } from '@angular/core';
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent {
export class DeprecatedDashboardComponent {
hasGrafana = false; // TODO: Temporary var, remove when grafana is implemented
}

View File

@ -0,0 +1,167 @@
<div class="d-flex pl-1 pb-2 pt-2">
<div class="ms-2 me-auto">
<a [routerLink]="link"
*ngIf="link && total > 0; else noLinkTitle"
[ngPlural]="total"
i18n>
{{ total }}
<ng-template ngPluralCase="=0">{{ title }}</ng-template>
<ng-template ngPluralCase="=1">{{ title }}</ng-template>
<ng-template ngPluralCase="other">{{ title }}s</ng-template>
</a>
</div>
<ng-container [ngSwitch]="summaryType">
<ng-container *ngSwitchCase="'iscsi'">
<ng-container *ngTemplateOutlet="iscsiSummary"></ng-container>
</ng-container>
<ng-container *ngSwitchCase="'osd'">
<ng-container *ngTemplateOutlet="osdSummary"></ng-container>
</ng-container>
<ng-container *ngSwitchCase="'simplified'">
<ng-container *ngTemplateOutlet="simplifiedSummary"></ng-container>
</ng-container>
<ng-container *ngSwitchDefault>
<ng-container *ngTemplateOutlet="defaultSummary"></ng-container>
</ng-container>
</ng-container>
</div>
<ng-template #defaultSummary>
<span *ngIf="data.success || data.categoryPgAmount?.clean || (data.success === 0 && data.total === 0)">
<span *ngIf="data.success || (data.success === 0 && data.total === 0)">
{{ data.success }}
</span>
<span *ngIf="data.categoryPgAmount?.clean">
{{ data.categoryPgAmount?.clean }}
</span>
<i class="text-success"
[ngClass]="[icons.success]">
</i>
</span>
<span *ngIf="data.info"
class="ms-2">
<span *ngIf="data.info">
{{ data.info }}
</span>
<i class="text-info"
[ngClass]="[icons.danger]">
</i>
</span>
<span *ngIf="data.warn || data.categoryPgAmount?.warning"
class="ms-2">
<span *ngIf="data.warn">
{{ data.warn }}
</span>
<span *ngIf="data.categoryPgAmount?.warning">
{{ data.categoryPgAmount?.warning }}
</span>
<i class="text-warning"
[ngClass]="[icons.warning]">
</i>
</span>
<span *ngIf="data.error || data.categoryPgAmount?.unknown"
class="ms-2">
<span *ngIf="data.error">
{{ data.error }}
</span>
<span *ngIf="data.categoryPgAmount?.unknown">
{{ data.categoryPgAmount?.unknown }}
</span>
<i class="text-danger"
[ngClass]="[icons.danger]">
</i>
</span>
<span *ngIf="data.categoryPgAmount?.working"
class="ms-2">
<span *ngIf="data.categoryPgAmount?.working">
{{ data.categoryPgAmount?.working }}
</span>
<i class="text-warning"
[ngClass]="[icons.spinner, icons.spin]">
</i>
</span>
</ng-template>
<ng-template #osdSummary>
<span *ngIf="data.up === data.in">
{{ data.up }}
<i class="text-success"
[ngClass]="[icons.success]">
</i>
</span>
<span *ngIf="data.up !== data.in">
{{ data.up }}
<span class="fw-bold text-success">
up
</span>
</span>
<span *ngIf="data.in !== data.up"
class="ms-2">
{{ data.in }}
<span class="fw-bold text-success">
in
</span>
</span>
<span *ngIf="data.down"
class="ms-2">
{{ data.down }}
<span class="fw-bold text-danger me-2">
down
</span>
</span>
<span *ngIf="data.out"
class="ms-2">
{{ data.out }}
<span class="fw-bold text-danger me-2">
out
</span>
</span>
<span *ngIf="data.nearfull"
class="ms-2">
{{ data.nearfull }}
<span class="fw-bold text-warning me-2">
nearfull</span></span>
<span *ngIf="data.full"
class="ms-2">
{{ data.full }}
<span class="fw-bold text-danger">
full
</span>
</span>
</ng-template>
<ng-template #iscsiSummary>
<span>
{{ data.up }}
<i class="text-success"
*ngIf="data.up || data.up === 0"
[ngClass]="[icons.success]">
</i>
</span>
<span *ngIf="data.down"
class="ms-2">
{{ data.down }}
<i class="text-danger"
[ngClass]="[icons.danger]">
</i>
</span>
</ng-template>
<ng-template #simplifiedSummary>
<span>
{{ data }}
<i class="text-success"
[ngClass]="[icons.success]"></i>
</span>
</ng-template>
<ng-template #noLinkTitle>
<span *ngIf="total || total === 0"
[ngPlural]="total">
{{ total }}
<ng-template ngPluralCase="=0">{{ title }}</ng-template>
<ng-template ngPluralCase="=1">{{ title }}</ng-template>
<ng-template ngPluralCase="other">{{ title }}s</ng-template>
</span>
</ng-template>

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CardRowComponent } from './card-row.component';
describe('CardRowComponent', () => {
let component: CardRowComponent;
let fixture: ComponentFixture<CardRowComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CardRowComponent]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(CardRowComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,34 @@
import { Component, Input, OnChanges } from '@angular/core';
import { Icons } from '~/app/shared/enum/icons.enum';
@Component({
selector: 'cd-card-row',
templateUrl: './card-row.component.html',
styleUrls: ['./card-row.component.scss']
})
export class CardRowComponent implements OnChanges {
@Input()
title: string;
@Input()
link: string;
@Input()
data: any;
@Input()
summaryType = 'default';
icons = Icons;
total: number;
ngOnChanges(): void {
if (this.data.total || this.data.total === 0) {
this.total = this.data.total;
} else if (this.summaryType === 'iscsi') {
this.total = this.data.up + this.data.down || 0;
} else {
this.total = this.data;
}
}
}

View File

@ -0,0 +1,8 @@
<div class="card shadow-sm flex-fill">
<h4 class="card-title mt-4 ms-4 mb-0">
{{ title }}
</h4>
<div class="card-body ps-0 pe-0">
<ng-content></ng-content>
</div>
</div>

View File

@ -0,0 +1,5 @@
.card-body {
display: flex;
flex-direction: column;
justify-content: space-evenly;
}

View File

@ -0,0 +1,33 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { configureTestBed } from '~/testing/unit-test-helper';
import { CardComponent } from './card.component';
describe('CardComponent', () => {
let component: CardComponent;
let fixture: ComponentFixture<CardComponent>;
configureTestBed({
imports: [RouterTestingModule],
declarations: [CardComponent]
});
beforeEach(() => {
fixture = TestBed.createComponent(CardComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('Setting cards title makes title visible', () => {
const title = 'Card Title';
component.title = title;
fixture.detectChanges();
const titleDiv = fixture.debugElement.nativeElement.querySelector('.card-title');
expect(titleDiv.textContent).toContain(title);
});
});

View File

@ -0,0 +1,11 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'cd-card',
templateUrl: './card.component.html',
styleUrls: ['./card.component.scss']
})
export class CardComponent {
@Input()
title: string;
}

View File

@ -0,0 +1,23 @@
<div class="row">
<div class="col-3 center-text">
<br>
<b class="chartTitle"
i18n>{{ chartTitle }}</b>
<br>
<span [ngbTooltip]="label"
i18n>{{currentData}} {{ currentDataUnits }}</span>
<br>
<span [ngbTooltip]="label2"
i18n>{{currentData2}} {{ currentDataUnits2 }}</span>
</div>
<div class="col-9">
<div class="chart">
<canvas baseChart
[datasets]="chartData.dataset"
[options]="options"
[chartType]="'line'"
[plugins]="chartAreaBorderPlugin">
</canvas>
</div>
</div>
</div>

View File

@ -0,0 +1,9 @@
.center-text {
margin-top: 1.2vw;
position: relative;
}
.chart {
height: 8vh;
margin-top: 15px;
}

View File

@ -0,0 +1,36 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CssHelper } from '~/app/shared/classes/css-helper';
import { DimlessBinaryPerSecondPipe } from '~/app/shared/pipes/dimless-binary-per-second.pipe';
import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
import { FormatterService } from '~/app/shared/services/formatter.service';
import { configureTestBed } from '~/testing/unit-test-helper';
import { DashboardAreaChartComponent } from './dashboard-area-chart.component';
describe('DashboardAreaChartComponent', () => {
let component: DashboardAreaChartComponent;
let fixture: ComponentFixture<DashboardAreaChartComponent>;
configureTestBed({
schemas: [NO_ERRORS_SCHEMA],
declarations: [DashboardAreaChartComponent],
providers: [
CssHelper,
DimlessBinaryPipe,
DimlessBinaryPerSecondPipe,
DimlessPipe,
FormatterService
]
});
beforeEach(() => {
fixture = TestBed.createComponent(DashboardAreaChartComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,265 @@
import { AfterViewInit, Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
import { CssHelper } from '~/app/shared/classes/css-helper';
import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
import { DimlessBinaryPerSecondPipe } from '~/app/shared/pipes/dimless-binary-per-second.pipe';
import { FormatterService } from '~/app/shared/services/formatter.service';
import { BaseChartDirective, PluginServiceGlobalRegistrationAndOptions } from 'ng2-charts';
import { DimlessPipe } from '~/app/shared/pipes/dimless.pipe';
@Component({
selector: 'cd-dashboard-area-chart',
templateUrl: './dashboard-area-chart.component.html',
styleUrls: ['./dashboard-area-chart.component.scss']
})
export class DashboardAreaChartComponent implements OnInit, OnChanges, AfterViewInit {
@ViewChild(BaseChartDirective) chart: BaseChartDirective;
@Input()
chartTitle: string;
@Input()
maxValue?: any;
@Input()
dataUnits: string;
@Input()
data: any;
@Input()
data2?: any;
@Input()
label: any;
@Input()
label2?: any;
currentDataUnits: string;
currentData: number;
currentDataUnits2?: string;
currentData2?: number;
chartData: any = {
dataset: [
{
label: '',
data: [{ x: 0, y: 0 }],
tension: 0,
pointBackgroundColor: this.cssHelper.propertyValue('chart-color-strong-blue'),
backgroundColor: this.cssHelper.propertyValue('chart-color-translucent-blue'),
borderColor: this.cssHelper.propertyValue('chart-color-strong-blue')
},
{
label: '',
data: [],
tension: 0,
pointBackgroundColor: this.cssHelper.propertyValue('chart-color-orange'),
backgroundColor: this.cssHelper.propertyValue('chart-color-yellow'),
borderColor: this.cssHelper.propertyValue('chart-color-orange')
}
]
};
options: any = {
responsive: true,
maintainAspectRatio: false,
elements: {
point: {
radius: 0
}
},
legend: {
display: false
},
tooltips: {
intersect: false,
displayColors: true,
backgroundColor: this.cssHelper.propertyValue('chart-color-tooltip-background'),
callbacks: {
title: function (tooltipItem: any): any {
return tooltipItem[0].xLabel;
}
}
},
hover: {
intersect: false
},
scales: {
xAxes: [
{
display: false,
type: 'time',
gridLines: {
display: false
},
time: {
tooltipFormat: 'YYYY/MM/DD hh:mm:ss'
}
}
],
yAxes: [
{
gridLines: {
display: false
},
ticks: {
beginAtZero: true,
maxTicksLimit: 3,
callback: (value: any) => {
if (value === 0) {
return null;
}
return this.fillString(this.convertUnits(value));
}
}
}
]
},
plugins: {
borderArea: true,
chartAreaBorder: {
borderColor: this.cssHelper.propertyValue('chart-color-slight-dark-gray'),
borderWidth: 2
}
}
};
public chartAreaBorderPlugin: PluginServiceGlobalRegistrationAndOptions[] = [
{
beforeDraw(chart: Chart) {
if (!chart.options.plugins.borderArea) {
return;
}
const {
ctx,
chartArea: { left, top, right, bottom }
} = chart;
ctx.save();
ctx.strokeStyle = chart.options.plugins.chartAreaBorder.borderColor;
ctx.lineWidth = chart.options.plugins.chartAreaBorder.borderWidth;
ctx.setLineDash(chart.options.plugins.chartAreaBorder.borderDash || []);
ctx.lineDashOffset = chart.options.plugins.chartAreaBorder.borderDashOffset;
ctx.strokeRect(left, top, right - left - 1, bottom);
ctx.restore();
}
}
];
constructor(
private cssHelper: CssHelper,
private dimlessBinary: DimlessBinaryPipe,
private dimlessBinaryPerSecond: DimlessBinaryPerSecondPipe,
private dimlessPipe: DimlessPipe,
private formatter: FormatterService
) {}
ngOnInit(): void {
this.currentData = Number(
this.chartData.dataset[0].data[this.chartData.dataset[0].data.length - 1].y
);
if (this.data2) {
this.currentData2 = Number(
this.chartData.dataset[1].data[this.chartData.dataset[1].data.length - 1].y
);
}
}
ngOnChanges(): void {
if (this.data) {
this.setChartTicks();
this.chartData.dataset[0].data = this.formatData(this.data);
this.chartData.dataset[0].label = this.label;
[this.currentData, this.currentDataUnits] = this.convertUnits(
this.data[this.data.length - 1][1]
).split(' ');
}
if (this.data2) {
this.chartData.dataset[1].data = this.formatData(this.data2);
this.chartData.dataset[1].label = this.label2;
[this.currentData2, this.currentDataUnits2] = this.convertUnits(
this.data2[this.data2.length - 1][1]
).split(' ');
}
}
ngAfterViewInit(): void {
if (this.data) {
this.setChartTicks();
}
}
private formatData(array: Array<any>): any {
let formattedData = {};
formattedData = array.map((data: any) => ({
x: data[0] * 1000,
y: Number(this.convertUnits(data[1]).replace(/[^\d,.]+/g, ''))
}));
return formattedData;
}
private convertUnits(data: any): any {
let dataWithUnits: string;
if (this.dataUnits === 'bytes') {
dataWithUnits = this.dimlessBinary.transform(data);
} else if (this.dataUnits === 'bytesPerSecond') {
dataWithUnits = this.dimlessBinaryPerSecond.transform(data);
} else if (this.dataUnits === 'ms') {
dataWithUnits = this.formatter.format_number(data, 1000, ['ms', 's']);
} else {
dataWithUnits = this.dimlessPipe.transform(data);
}
return dataWithUnits;
}
private fillString(str: string): string {
let maxNumberOfChar: number = 8;
let numberOfChars: number = str.length;
if (str.length < 4) {
maxNumberOfChar = 11;
}
for (; numberOfChars < maxNumberOfChar; numberOfChars++) {
str = '\u00A0' + str;
}
return str + '\u00A0\u00A0';
}
private setChartTicks() {
if (this.chart && this.maxValue) {
let [maxValue, maxValueDataUnits] = this.convertUnits(this.maxValue).split(' ');
this.chart.chart.options.scales.yAxes[0].ticks.suggestedMax = maxValue;
this.chart.chart.options.scales.yAxes[0].ticks.suggestedMin = 0;
this.chart.chart.options.scales.yAxes[0].ticks.stepSize = Number((maxValue / 2).toFixed(0));
this.chart.chart.options.scales.yAxes[0].ticks.callback = (value: any) => {
if (value === 0) {
return null;
}
return this.fillString(`${value} ${maxValueDataUnits}`);
};
this.chart.chart.update();
} else if (this.chart && this.data) {
let maxValue = 0,
maxValueDataUnits = '';
let maxValueData = Math.max(...this.data.map((values: any) => values[1]));
if (this.data2) {
var maxValueData2 = Math.max(...this.data2.map((values: any) => values[1]));
[maxValue, maxValueDataUnits] = this.convertUnits(
Math.max(maxValueData, maxValueData2)
).split(' ');
} else {
[maxValue, maxValueDataUnits] = this.convertUnits(Math.max(maxValueData)).split(' ');
}
this.chart.chart.options.scales.yAxes[0].ticks.suggestedMax = maxValue * 1.2;
this.chart.chart.options.scales.yAxes[0].ticks.suggestedMin = 0;
this.chart.chart.options.scales.yAxes[0].ticks.stepSize = Number(
((maxValue * 1.2) / 2).toFixed(0)
);
this.chart.chart.options.scales.yAxes[0].ticks.callback = (value: any) => {
if (value === 0) {
return null;
}
if (!maxValueDataUnits) {
return this.fillString(`${value}`);
}
return this.fillString(`${value} ${maxValueDataUnits}`);
};
this.chart.chart.update();
}
}
}

View File

@ -0,0 +1,16 @@
<div class="chart-container">
<canvas baseChart
#chartCanvas
[datasets]="chartConfig.dataset"
[chartType]="chartConfig.chartType"
[options]="chartConfig.options"
[labels]="chartConfig.labels"
[colors]="chartConfig.colors"
[plugins]="doughnutChartPlugins"
class="chart-canvas">
</canvas>
<div class="chartjs-tooltip"
#chartTooltip>
<table></table>
</div>
</div>

View File

@ -0,0 +1,22 @@
@use './src/styles/chart-tooltip';
$canvas-width: 100%;
$canvas-height: 100%;
.chart-container {
height: $canvas-height;
margin-left: auto;
margin-right: auto;
position: unset;
width: $canvas-width;
}
.chart-canvas {
height: $canvas-height;
margin-left: auto;
margin-right: auto;
max-height: $canvas-height;
max-width: $canvas-width;
position: unset;
width: $canvas-width;
}

View File

@ -0,0 +1,27 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CssHelper } from '~/app/shared/classes/css-helper';
import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
import { configureTestBed } from '~/testing/unit-test-helper';
import { DashboardPieComponent } from './dashboard-pie.component';
describe('DashboardPieComponent', () => {
let component: DashboardPieComponent;
let fixture: ComponentFixture<DashboardPieComponent>;
configureTestBed({
schemas: [NO_ERRORS_SCHEMA],
declarations: [DashboardPieComponent],
providers: [CssHelper, DimlessBinaryPipe]
});
beforeEach(() => {
fixture = TestBed.createComponent(DashboardPieComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,189 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core';
import * as Chart from 'chart.js';
import _ from 'lodash';
import { PluginServiceGlobalRegistrationAndOptions } from 'ng2-charts';
import { CssHelper } from '~/app/shared/classes/css-helper';
import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
@Component({
selector: 'cd-dashboard-pie',
templateUrl: './dashboard-pie.component.html',
styleUrls: ['./dashboard-pie.component.scss']
})
export class DashboardPieComponent implements OnChanges, OnInit {
@Input()
data: any;
@Input()
highThreshold: number;
@Input()
lowThreshold: number;
color: string;
chartConfig: any = {
chartType: 'doughnut',
labels: ['', '', ''],
dataset: [
{
label: null,
backgroundColor: [
this.cssHelper.propertyValue('chart-color-light-gray'),
this.cssHelper.propertyValue('chart-color-slight-dark-gray'),
this.cssHelper.propertyValue('chart-color-dark-gray')
]
},
{
label: null,
borderWidth: 0,
backgroundColor: [
this.cssHelper.propertyValue('chart-color-blue'),
this.cssHelper.propertyValue('chart-color-white')
]
}
],
options: {
cutoutPercentage: 70,
events: ['click', 'mouseout', 'touchstart'],
legend: {
display: true,
position: 'right',
labels: {
boxWidth: 10,
usePointStyle: false,
generateLabels: (chart: any) => {
const labels = { 0: {}, 1: {}, 2: {} };
labels[0] = {
text: $localize`Used: ${chart.data.datasets[1].data[2]}`,
fillStyle: chart.data.datasets[1].backgroundColor[0],
strokeStyle: chart.data.datasets[1].backgroundColor[0]
};
labels[1] = {
text: $localize`Warning: ${chart.data.datasets[0].data[0]}%`,
fillStyle: chart.data.datasets[0].backgroundColor[1],
strokeStyle: chart.data.datasets[0].backgroundColor[1]
};
labels[2] = {
text: $localize`Danger: ${
chart.data.datasets[0].data[0] + chart.data.datasets[0].data[1]
}%`,
fillStyle: chart.data.datasets[0].backgroundColor[2],
strokeStyle: chart.data.datasets[0].backgroundColor[2]
};
return labels;
}
}
},
plugins: {
center_text: true
},
tooltips: {
enabled: true,
displayColors: false,
backgroundColor: this.cssHelper.propertyValue('chart-color-tooltip-background'),
cornerRadius: 0,
bodyFontSize: 14,
bodyFontStyle: '600',
position: 'nearest',
xPadding: 12,
yPadding: 12,
filter: (tooltipItem: any) => {
return tooltipItem.datasetIndex === 1;
},
callbacks: {
label: (item: Record<string, any>, data: Record<string, any>) => {
let text = data.labels[item.index];
if (!text.includes('%')) {
text = `${text} (${data.datasets[item.datasetIndex].data[item.index]}%)`;
}
return text;
}
}
},
title: {
display: false
}
}
};
public doughnutChartPlugins: PluginServiceGlobalRegistrationAndOptions[] = [
{
id: 'center_text',
beforeDraw(chart: Chart) {
const cssHelper = new CssHelper();
const defaultFontFamily = 'Helvetica Neue, Helvetica, Arial, sans-serif';
Chart.defaults.global.defaultFontFamily = defaultFontFamily;
const ctx = chart.ctx;
if (!chart.options.plugins.center_text || !chart.data.datasets[0].label) {
return;
}
ctx.save();
const label = chart.data.datasets[0].label[0].split('\n');
const centerX = (chart.chartArea.left + chart.chartArea.right) / 2;
const centerY = (chart.chartArea.top + chart.chartArea.bottom) / 2;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `24px ${defaultFontFamily}`;
ctx.fillText(label[0], centerX, centerY - 10);
if (label.length > 1) {
ctx.font = `14px ${defaultFontFamily}`;
ctx.fillStyle = cssHelper.propertyValue('chart-color-center-text-description');
ctx.fillText(label[1], centerX, centerY + 10);
}
ctx.restore();
}
}
];
constructor(private cssHelper: CssHelper, private dimlessBinary: DimlessBinaryPipe) {}
ngOnInit() {
this.prepareRawUsage(this.chartConfig, this.data);
}
ngOnChanges() {
this.prepareRawUsage(this.chartConfig, this.data);
}
private prepareRawUsage(chart: Record<string, any>, data: Record<string, any>) {
const nearFullRatioPercent = this.lowThreshold * 100;
const fullRatioPercent = this.highThreshold * 100;
const percentAvailable = this.calcPercentage(data.max - data.current, data.max);
const percentUsed = this.calcPercentage(data.current, data.max);
if (percentUsed >= fullRatioPercent) {
this.color = 'chart-color-red';
} else if (percentUsed >= nearFullRatioPercent) {
this.color = 'chart-color-yellow';
} else {
this.color = 'chart-color-blue';
}
chart.dataset[0].data = [
Math.round(nearFullRatioPercent),
Math.round(Math.abs(nearFullRatioPercent - fullRatioPercent)),
Math.round(100 - fullRatioPercent)
];
chart.dataset[1].data = [
percentUsed,
percentAvailable,
this.dimlessBinary.transform(data.current)
];
chart.dataset[1].backgroundColor[0] = this.cssHelper.propertyValue(this.color);
chart.dataset[0].label = [`${percentUsed}%\nof ${this.dimlessBinary.transform(data.max)}`];
}
private calcPercentage(dividend: number, divisor: number) {
if (!_.isNumber(dividend) || !_.isNumber(divisor) || divisor === 0) {
return 0;
}
return Math.ceil((dividend / divisor) * 100 * 100) / 100;
}
}

View File

@ -0,0 +1,11 @@
<div class="timeSelector">
<select id="timepicker"
name="timepicker"
[(ngModel)]="time"
(ngModelChange)="emitTime()"
class="form-select">
<option *ngFor="let key of times"
[ngValue]="key.value">{{ key.name }}
</option>
</select>
</div>

View File

@ -0,0 +1,10 @@
select#timepicker {
border: 0;
}
.timeSelector {
position: absolute;
right: 18px;
top: 20px;
width: 12rem;
}

View File

@ -0,0 +1,24 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { configureTestBed } from '~/testing/unit-test-helper';
import { DashboardTimeSelectorComponent } from './dashboard-time-selector.component';
describe('DashboardTimeSelectorComponent', () => {
let component: DashboardTimeSelectorComponent;
let fixture: ComponentFixture<DashboardTimeSelectorComponent>;
configureTestBed({
schemas: [NO_ERRORS_SCHEMA],
declarations: [DashboardTimeSelectorComponent]
});
beforeEach(() => {
fixture = TestBed.createComponent(DashboardTimeSelectorComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,77 @@
import { Component, EventEmitter, Output } from '@angular/core';
import moment from 'moment';
@Component({
selector: 'cd-dashboard-time-selector',
templateUrl: './dashboard-time-selector.component.html',
styleUrls: ['./dashboard-time-selector.component.scss']
})
export class DashboardTimeSelectorComponent {
@Output()
selectedTime = new EventEmitter<any>();
times: any;
time: any;
constructor() {
this.times = [
{
name: $localize`Last 5 minutes`,
value: this.timeToDate(5 * 60, 1)
},
{
name: $localize`Last 15 minutes`,
value: this.timeToDate(15 * 60, 3)
},
{
name: $localize`Last 30 minutes`,
value: this.timeToDate(30 * 60, 6)
},
{
name: $localize`Last 1 hour`,
value: this.timeToDate(3600, 12)
},
{
name: $localize`Last 3 hours`,
value: this.timeToDate(3 * 3600, 36)
},
{
name: $localize`Last 6 hours`,
value: this.timeToDate(6 * 3600, 72)
},
{
name: $localize`Last 12 hours`,
value: this.timeToDate(12 * 3600, 144)
},
{
name: $localize`Last 24 hours`,
value: this.timeToDate(24 * 3600, 288)
},
{
name: $localize`Last 2 days`,
value: this.timeToDate(48 * 3600, 576)
},
{
name: $localize`Last 7 days`,
value: this.timeToDate(168 * 3600, 2016)
}
];
this.time = this.times[3].value;
}
emitTime() {
this.selectedTime.emit(this.timeToDate(this.time.end - this.time.start, this.time.step));
}
private timeToDate(secondsAgo: number, step: number): any {
const date: number = moment().unix() - secondsAgo;
const dateNow: number = moment().unix();
const formattedDate: any = {
start: date,
end: dateNow,
step: step
};
return formattedDate;
}
}

View File

@ -0,0 +1,45 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { NgbNavModule, NgbPopoverModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ChartsModule } from 'ng2-charts';
import { SimplebarAngularModule } from 'simplebar-angular';
import { SharedModule } from '~/app/shared/shared.module';
import { CephSharedModule } from '../shared/ceph-shared.module';
import { CardComponent } from './card/card.component';
import { DashboardAreaChartComponent } from './dashboard-area-chart/dashboard-area-chart.component';
import { DashboardPieComponent } from './dashboard-pie/dashboard-pie.component';
import { DashboardTimeSelectorComponent } from './dashboard-time-selector/dashboard-time-selector.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { CardRowComponent } from './card-row/card-row.component';
import { PgSummaryPipe } from './pg-summary.pipe';
@NgModule({
imports: [
CephSharedModule,
CommonModule,
NgbNavModule,
SharedModule,
ChartsModule,
RouterModule,
NgbPopoverModule,
NgbTooltipModule,
FormsModule,
ReactiveFormsModule,
SimplebarAngularModule
],
declarations: [
DashboardComponent,
CardComponent,
DashboardPieComponent,
CardRowComponent,
PgSummaryPipe,
DashboardAreaChartComponent,
DashboardTimeSelectorComponent
]
})
export class NewDashboardModule {}

View File

@ -0,0 +1,257 @@
<div class="container-fluid"
*ngIf="healthData && enabledFeature$ | async as enabledFeature">
<div class="row mx-0">
<cd-card title="Details"
i18n-title
class="col-sm-3 px-3"
[ngClass]="{'d-flex': flexHeight}">
<dl class="ms-4 me-4">
<dt>FSID</dt>
<dd>{{ detailsCardData.fsid }}</dd>
<dt>Orchestrator</dt>
<dd i18n>{{ detailsCardData.orchestrator || 'Orchestrator is not available' }}</dd>
<dt>Ceph version</dt>
<dd>{{ detailsCardData.cephVersion }}</dd>
</dl>
</cd-card>
<cd-card title="Status"
i18n-title
class="col-sm-6 px-3 d-flex">
<div class="d-flex ms-4 me-4 mb-5 center-content">
<i *ngIf="healthData.health?.status"
[ngClass]="[healthData.health.status | healthIcon, icons.large2x]"
[ngStyle]="healthData.health.status | healthColor"
[title]="healthData.health.status"></i>
<span class="ms-2 mt-n1 lead"
i18n>Cluster</span>
</div>
<section class="border-top mt-5"
*ngIf="isAlertmanagerConfigured && (crticialActiveAlerts || warningActiveAlerts)">
<div class="d-flex flex-wrap ms-4 me-4">
<span class="pt-2"
i18n>Alerts</span>
<!-- Potentially make widget component -->
<button class="btn btn-outline-danger rounded-pill ms-2"
[ngClass]="{'active': showAlerts && alertType === 'critical'}"
title="Danger"
(click)="toggleAlertsWindow('danger')"
id="dangerAlerts"
i18n-title
*ngIf="crticialActiveAlerts">
<i [ngClass]="[icons.danger]"></i>
<span>{{ crticialActiveAlerts }}</span>
</button>
<button class="btn btn-outline-warning rounded-pill ms-2"
[ngClass]="{'active': showAlerts && alertType === 'warning'}"
title="Warning"
(click)="toggleAlertsWindow('warning')"
id="warningAlerts"
i18n-title
*ngIf="warningActiveAlerts">
<i [ngClass]="[icons.infoCircle]"></i>
<span>{{ warningActiveAlerts }}</span>
</button>
<div class="pt-0 position-right">
<button class="btn btn-block dropdown-toggle"
data-toggle="collapse"
aria-label="toggle alert window"
[attr.aria-expanded]="showAlerts"
(click)="toggleAlertsWindow('danger', 'true')"></button>
</div>
</div>
<div class="alerts pt-0"
*ngIf="showAlerts">
<hr class="mt-4">
<ngx-simplebar [options]="simplebar">
<div class="card-body ps-0 pe-1 pt-1">
<ng-container *ngTemplateOutlet="alertsCard"></ng-container>
</div>
</ngx-simplebar>
</div>
</section>
</cd-card>
<cd-card title="Capacity"
i18n-title
class="col-sm-3 px-3"
[ngClass]="{'d-flex': flexHeight}">
<ng-container class="ms-4 me-4"
*ngIf="capacity && osdSettings">
<cd-dashboard-pie [data]="{max: capacity.total_bytes, current: capacity.total_used_raw_bytes}"
[lowThreshold]="osdSettings.nearfull_ratio"
[highThreshold]="osdSettings.full_ratio">
</cd-dashboard-pie>
</ng-container>
</cd-card>
</div>
<!-- Second row -->
<div class="row mx-0">
<!-- Inventory Card -->
<cd-card title="Inventory"
i18n-title
class="col-sm-3 px-3 d-flex">
<hr>
<!-- Hosts -->
<li class="list-group-item">
<cd-card-row [data]="healthData.hosts"
link="/hosts"
title="Host"
summaryType="simplified"
*ngIf="healthData.hosts != null"></cd-card-row>
</li>
<hr>
<!-- Monitors -->
<li class="list-group-item">
<cd-card-row [data]="healthData.mon_status.monmap.mons.length"
link="/monitor"
title="Monitor"
summaryType="simplified"
*ngIf="healthData.mon_status"></cd-card-row>
</li>
<hr>
<!-- Managers -->
<li *ngIf="healthData.mgr_map"
class="list-group-item">
<cd-card-row [data]="healthData.mgr_map | mgrSummary"
link="/manager"
title="Manager"
*ngIf="healthData.mgr_map"></cd-card-row>
</li>
<hr>
<!-- OSDs -->
<li class="list-group-item">
<cd-card-row [data]="healthData.osd_map | osdSummary"
link="/osd"
title="OSD"
summaryType="osd"
*ngIf="healthData.osd_map"></cd-card-row>
</li>
<hr>
<!-- Pools -->
<li *ngIf="healthData.pools"
class="list-group-item">
<cd-card-row [data]="healthData.pools.length"
link="/pool"
title="Pool"
summaryType="simplified"
*ngIf="healthData.pools"></cd-card-row>
</li>
<hr>
<!-- PG Info -->
<li class="list-group-item">
<cd-card-row [data]="healthData.pg_info | pgSummary"
title="PG"
*ngIf="healthData.pg_info"></cd-card-row>
</li>
<hr>
<!-- Object gateways -->
<li *ngIf="enabledFeature.rgw && healthData.rgw != null"
class="list-group-item"
id="rgw-item">
<cd-card-row [data]="healthData.rgw"
link="/rgw/daemon"
title="Object Gateway"
summaryType="simplified"
*ngIf="healthData.rgw || healthData.rgw === 0 "></cd-card-row>
</li>
<hr>
<!-- Metadata Servers -->
<li *ngIf="enabledFeature.cephfs && healthData.fs_map"
class="list-group-item"
id="mds-item">
<cd-card-row [data]="healthData.fs_map | mdsSummary"
title="Metadata Server"
*ngIf="healthData.fs_map"></cd-card-row>
</li>
<hr>
<!-- iSCSI Gateways -->
<li *ngIf="enabledFeature.iscsi && healthData.iscsi_daemons != null"
class="list-group-item"
id="iscsi-item">
<cd-card-row [data]="healthData.iscsi_daemons"
link="/iscsi/daemon"
title="iSCSI Gateway"
summaryType="iscsi"
*ngIf="healthData.iscsi_daemons"></cd-card-row>
</li>
</cd-card>
<cd-card title="Cluster utilization"
i18n-title
class="col-sm-9 px-3 d-flex">
<div class="ms-4 me-4 mt-0">
<cd-dashboard-time-selector (selectedTime)="getPrometheusData($event)">
</cd-dashboard-time-selector>
<ng-container *ngIf="capacity">
<cd-dashboard-area-chart chartTitle="Used Capacity"
[maxValue]="capacity.total_bytes"
dataUnits="bytes"
label="Used Capacity"
[data]="queriesResults.USEDCAPACITY">
</cd-dashboard-area-chart>
</ng-container>
<cd-dashboard-area-chart chartTitle="IOPS"
dataUnits="none"
label="OPS"
label2="IPS"
[data]="queriesResults.OPS"
[data2]="queriesResults.IPS">
</cd-dashboard-area-chart>
<cd-dashboard-area-chart chartTitle="Latency"
dataUnits="ms"
label="Read"
label2="Write"
[data]="queriesResults.READLATENCY"
[data2]="queriesResults.WRITELATENCY">
</cd-dashboard-area-chart>
<cd-dashboard-area-chart chartTitle="Client Throughput"
dataUnits="bytesPerSecond"
label="Read"
label2="Write"
[data]="queriesResults.READCLIENTTHROUGHPUT"
[data2]="queriesResults.WRITECLIENTTHROUGHPUT">
</cd-dashboard-area-chart>
<cd-dashboard-area-chart chartTitle="Recovery Throughput"
dataUnits="bytesPerSecond"
label="Recovery Throughput"
[data]="queriesResults.RECOVERYBYTES">
</cd-dashboard-area-chart>
</div>
</cd-card>
</div>
</div>
<ng-template #alertsCard>
<ng-container *ngFor="let alert of alerts; let i = index">
<div [ngClass]="borderClass"
*ngIf="alertType === alert.labels.severity">
<div class="card tc_alerts border-0 pt-3">
<div class="row no-gutters">
<div class="col-sm-1 text-center">
<span [ngClass]="[icons.stack, icons.large, textClass]">
<i [ngClass]="[icons.circle, icons.stack2x]"></i>
<i [ngClass]="[icons.stack1x, icons.inverse, icons.warning]"></i>
</span>
</div>
<div class="col-md-11">
<div class="card-body ps-0 pe-1 pt-1">
<h6 class="card-title bold">{{ alert.labels.alertname }}</h6>
<p class="card-text me-3"
[innerHtml]="alert.annotations.summary"></p>
<p class="card-text text-muted me-3">
<small class="date"
[title]="alert.startsAt | cdDate"
i18n>Active since: {{ alert.startsAt | relativeDate }}</small>
</p>
</div>
</div>
</div>
</div>
<hr>
</div>
</ng-container>
</ng-template>

View File

@ -0,0 +1,67 @@
.alerts {
height: 17rem;
div {
padding-top: 0;
}
}
div {
padding-top: 20px;
}
ngx-simplebar {
height: 18rem;
}
hr {
margin-bottom: 2px;
margin-top: 2px;
}
.position-right {
margin-left: auto;
order: 2;
}
.center-content {
align-items: center;
margin-top: 30px;
position: relative;
}
button.dropdown-toggle {
position: relative;
&::after {
border: 0;
content: '\f054';
font-family: 'ForkAwesome';
font-size: 1rem;
position: absolute;
right: 20px;
transition: transform 0.3s ease-in-out;
}
&[aria-expanded='true']::after {
transform: rotate(90deg);
}
&:focus {
box-shadow: none;
}
}
.list-group-item {
border: 0;
}
dt {
font-size: larger;
margin-bottom: 0.3rem;
}
dd {
font-size: larger;
margin-bottom: 0.8rem;
}

View File

@ -0,0 +1,316 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import _ from 'lodash';
import { ToastrModule } from 'ngx-toastr';
import { BehaviorSubject, of } from 'rxjs';
import { ConfigurationService } from '~/app/shared/api/configuration.service';
import { HealthService } from '~/app/shared/api/health.service';
import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
import { PrometheusService } from '~/app/shared/api/prometheus.service';
import { CssHelper } from '~/app/shared/classes/css-helper';
import { AlertmanagerAlert } from '~/app/shared/models/prometheus-alerts';
import { FeatureTogglesService } from '~/app/shared/services/feature-toggles.service';
import { SummaryService } from '~/app/shared/services/summary.service';
import { SharedModule } from '~/app/shared/shared.module';
import { configureTestBed } from '~/testing/unit-test-helper';
import { PgCategoryService } from '../../shared/pg-category.service';
import { CardRowComponent } from '../card-row/card-row.component';
import { CardComponent } from '../card/card.component';
import { DashboardPieComponent } from '../dashboard-pie/dashboard-pie.component';
import { PgSummaryPipe } from '../pg-summary.pipe';
import { DashboardComponent } from './dashboard.component';
export class SummaryServiceMock {
summaryDataSource = new BehaviorSubject({
version:
'ceph version 17.0.0-12222-gcd0cd7cb ' +
'(b8193bb4cda16ccc5b028c3e1df62bc72350a15d) quincy (dev)'
});
summaryData$ = this.summaryDataSource.asObservable();
subscribe(call: any) {
return this.summaryData$.subscribe(call);
}
}
describe('Dashbord Component', () => {
let component: DashboardComponent;
let fixture: ComponentFixture<DashboardComponent>;
let configurationService: ConfigurationService;
let orchestratorService: MgrModuleService;
let getHealthSpy: jasmine.Spy;
let getAlertsSpy: jasmine.Spy;
let fakeFeatureTogglesService: jasmine.Spy;
const healthPayload: Record<string, any> = {
health: { status: 'HEALTH_OK' },
mon_status: { monmap: { mons: [] }, quorum: [] },
osd_map: { osds: [] },
mgr_map: { standbys: [] },
hosts: 0,
rgw: 0,
fs_map: { filesystems: [], standbys: [] },
iscsi_daemons: 1,
client_perf: {},
scrub_status: 'Inactive',
pools: [],
df: { stats: {} },
pg_info: { object_stats: { num_objects: 1 } }
};
const alertsPayload: AlertmanagerAlert[] = [
{
labels: {
alertname: 'CephMgrPrometheusModuleInactive',
instance: 'ceph2:9283',
job: 'ceph',
severity: 'critical'
},
annotations: {
description: 'The mgr/prometheus module at ceph2:9283 is unreachable.',
summary: 'The mgr/prometheus module is not available'
},
startsAt: '2022-09-28T08:23:41.152Z',
endsAt: '2022-09-28T15:28:01.152Z',
generatorURL: 'http://prometheus:9090/testUrl',
status: {
state: 'active',
silencedBy: null,
inhibitedBy: null
},
receivers: ['ceph2'],
fingerprint: 'fingerprint'
},
{
labels: {
alertname: 'CephOSDDownHigh',
instance: 'ceph:9283',
job: 'ceph',
severity: 'critical'
},
annotations: {
description: '66.67% or 2 of 3 OSDs are down (>= 10%).',
summary: 'More than 10% of OSDs are down'
},
startsAt: '2022-09-28T14:17:22.665Z',
endsAt: '2022-09-28T15:28:32.665Z',
generatorURL: 'http://prometheus:9090/testUrl',
status: {
state: 'active',
silencedBy: null,
inhibitedBy: null
},
receivers: ['default'],
fingerprint: 'fingerprint'
},
{
labels: {
alertname: 'CephHealthWarning',
instance: 'ceph:9283',
job: 'ceph',
severity: 'warning'
},
annotations: {
description: 'The cluster state has been HEALTH_WARN for more than 15 minutes.',
summary: 'Ceph is in the WARNING state'
},
startsAt: '2022-09-28T08:41:38.454Z',
endsAt: '2022-09-28T15:28:38.454Z',
generatorURL: 'http://prometheus:9090/testUrl',
status: {
state: 'active',
silencedBy: null,
inhibitedBy: null
},
receivers: ['ceph'],
fingerprint: 'fingerprint'
}
];
const configValueData: any = {
value: [
{
section: 'mgr',
value: 'e90a0d58-658e-4148-8f61-e896c86f0696'
}
]
};
const orchData: any = {
log_level: '',
log_to_cluster: false,
log_to_cluster_level: 'info',
log_to_file: false,
orchestrator: 'cephadm'
};
configureTestBed({
imports: [RouterTestingModule, HttpClientTestingModule, ToastrModule.forRoot(), SharedModule],
declarations: [
DashboardComponent,
CardComponent,
DashboardPieComponent,
CardRowComponent,
PgSummaryPipe
],
schemas: [NO_ERRORS_SCHEMA],
providers: [
{ provide: SummaryService, useClass: SummaryServiceMock },
CssHelper,
PgCategoryService
]
});
beforeEach(() => {
fakeFeatureTogglesService = spyOn(TestBed.inject(FeatureTogglesService), 'get').and.returnValue(
of({
rbd: true,
mirroring: true,
iscsi: true,
cephfs: true,
rgw: true
})
);
fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
configurationService = TestBed.inject(ConfigurationService);
orchestratorService = TestBed.inject(MgrModuleService);
getHealthSpy = spyOn(TestBed.inject(HealthService), 'getMinimalHealth');
getHealthSpy.and.returnValue(of(healthPayload));
spyOn(TestBed.inject(PrometheusService), 'ifAlertmanagerConfigured').and.callFake((fn) => fn());
getAlertsSpy = spyOn(TestBed.inject(PrometheusService), 'getAlerts');
getAlertsSpy.and.returnValue(of(alertsPayload));
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render all cards', () => {
fixture.detectChanges();
const dashboardCards = fixture.debugElement.nativeElement.querySelectorAll('cd-card');
expect(dashboardCards.length).toBe(5);
});
it('should get corresponding data into detailsCardData', () => {
spyOn(configurationService, 'get').and.returnValue(of(configValueData));
spyOn(orchestratorService, 'getConfig').and.returnValue(of(orchData));
component.ngOnInit();
expect(component.detailsCardData.fsid).toBe('e90a0d58-658e-4148-8f61-e896c86f0696');
expect(component.detailsCardData.orchestrator).toBe('Cephadm');
expect(component.detailsCardData.cephVersion).toBe('17.0.0-12222-gcd0cd7cb quincy (dev)');
});
it('should check if the respective icon is shown for each status', () => {
const payload = _.cloneDeep(healthPayload);
// HEALTH_WARN
payload.health['status'] = 'HEALTH_WARN';
payload.health['checks'] = [
{ severity: 'HEALTH_WARN', type: 'WRN', summary: { message: 'fake warning' } }
];
getHealthSpy.and.returnValue(of(payload));
fixture.detectChanges();
const clusterStatusCard = fixture.debugElement.query(By.css('cd-card[title="Status"] i'));
expect(clusterStatusCard.nativeElement.title).toEqual(`${payload.health.status}`);
// HEALTH_ERR
payload.health['status'] = 'HEALTH_ERR';
payload.health['checks'] = [
{ severity: 'HEALTH_ERR', type: 'ERR', summary: { message: 'fake error' } }
];
getHealthSpy.and.returnValue(of(payload));
fixture.detectChanges();
expect(clusterStatusCard.nativeElement.title).toEqual(`${payload.health.status}`);
// HEALTH_OK
payload.health['status'] = 'HEALTH_OK';
payload.health['checks'] = [
{ severity: 'HEALTH_OK', type: 'OK', summary: { message: 'fake success' } }
];
getHealthSpy.and.returnValue(of(payload));
fixture.detectChanges();
expect(clusterStatusCard.nativeElement.title).toEqual(`${payload.health.status}`);
});
it('should show the actual alert count on each alerts pill', () => {
fixture.detectChanges();
const successNotification = fixture.debugElement.query(By.css('button[id=warningAlerts] span'));
const dangerNotification = fixture.debugElement.query(By.css('button[id=dangerAlerts] span'));
expect(successNotification.nativeElement.textContent).toBe('1');
expect(dangerNotification.nativeElement.textContent).toBe('2');
});
it('should show the critical alerts window and its content', () => {
const payload = _.cloneDeep(alertsPayload[0]);
component.toggleAlertsWindow('danger');
fixture.detectChanges();
const cardTitle = fixture.debugElement.query(By.css('.tc_alerts h6.card-title'));
expect(cardTitle.nativeElement.textContent).toBe(payload.labels.alertname);
expect(component.alertType).not.toBe('warning');
});
it('should show the warning alerts window and its content', () => {
const payload = _.cloneDeep(alertsPayload[2]);
component.toggleAlertsWindow('warning');
fixture.detectChanges();
const cardTitle = fixture.debugElement.query(By.css('.tc_alerts h6.card-title'));
expect(cardTitle.nativeElement.textContent).toBe(payload.labels.alertname);
expect(component.alertType).not.toBe('critical');
});
it('should only show the pills when the alerts are not empty', () => {
getAlertsSpy.and.returnValue(of({}));
fixture.detectChanges();
const successNotification = fixture.debugElement.query(By.css('button[id=warningAlerts]'));
const dangerNotification = fixture.debugElement.query(By.css('button[id=dangerAlerts]'));
expect(successNotification).toBe(null);
expect(dangerNotification).toBe(null);
});
describe('features disabled', () => {
beforeEach(() => {
fakeFeatureTogglesService.and.returnValue(
of({
rbd: false,
mirroring: false,
iscsi: false,
cephfs: false,
rgw: false
})
);
fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
});
it('should not render items related to disabled features', () => {
fixture.detectChanges();
const iscsiCard = fixture.debugElement.query(By.css('li[id=iscsi-item]'));
const rgwCard = fixture.debugElement.query(By.css('li[id=rgw-item]'));
const mds = fixture.debugElement.query(By.css('li[id=mds-item]'));
expect(iscsiCard).toBeFalsy();
expect(rgwCard).toBeFalsy();
expect(mds).toBeFalsy();
});
});
});

View File

@ -0,0 +1,226 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import _ from 'lodash';
import { Observable, Subscription, timer } from 'rxjs';
import { take } from 'rxjs/operators';
import moment from 'moment';
import { ClusterService } from '~/app/shared/api/cluster.service';
import { ConfigurationService } from '~/app/shared/api/configuration.service';
import { HealthService } from '~/app/shared/api/health.service';
import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
import { OsdService } from '~/app/shared/api/osd.service';
import { PrometheusService } from '~/app/shared/api/prometheus.service';
import { Promqls as queries } from '~/app/shared/enum/dashboard-promqls.enum';
import { Icons } from '~/app/shared/enum/icons.enum';
import { DashboardDetails } from '~/app/shared/models/cd-details';
import { Permissions } from '~/app/shared/models/permissions';
import { AlertmanagerAlert } from '~/app/shared/models/prometheus-alerts';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import {
FeatureTogglesMap$,
FeatureTogglesService
} from '~/app/shared/services/feature-toggles.service';
import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
import { SummaryService } from '~/app/shared/services/summary.service';
@Component({
selector: 'cd-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit, OnDestroy {
detailsCardData: DashboardDetails = {};
osdSettingsService: any;
osdSettings: any;
interval = new Subscription();
permissions: Permissions;
enabledFeature$: FeatureTogglesMap$;
color: string;
capacityService: any;
capacity: any;
healthData$: Observable<Object>;
prometheusAlerts$: Observable<AlertmanagerAlert[]>;
isAlertmanagerConfigured = false;
icons = Icons;
showAlerts = false;
flexHeight = true;
simplebar = {
autoHide: false
};
textClass: string;
borderClass: string;
alertType: string;
alerts: AlertmanagerAlert[];
crticialActiveAlerts: number;
warningActiveAlerts: number;
healthData: any;
categoryPgAmount: Record<string, number> = {};
totalPgs = 0;
queriesResults: any = {
USEDCAPACITY: '',
IPS: '',
OPS: '',
READLATENCY: '',
WRITELATENCY: '',
READCLIENTTHROUGHPUT: '',
WRITECLIENTTHROUGHPUT: '',
RECOVERYBYTES: ''
};
timerGetPrometheusDataSub: Subscription;
timerTime = 30000;
readonly lastHourDateObject = {
start: moment().unix() - 3600,
end: moment().unix(),
step: 12
};
constructor(
private summaryService: SummaryService,
private configService: ConfigurationService,
private mgrModuleService: MgrModuleService,
private clusterService: ClusterService,
private osdService: OsdService,
private authStorageService: AuthStorageService,
private featureToggles: FeatureTogglesService,
private healthService: HealthService,
public prometheusService: PrometheusService,
private refreshIntervalService: RefreshIntervalService
) {
this.permissions = this.authStorageService.getPermissions();
this.enabledFeature$ = this.featureToggles.get();
}
ngOnInit() {
this.interval = this.refreshIntervalService.intervalData$.subscribe(() => {
this.getHealth();
this.triggerPrometheusAlerts();
this.getCapacityCardData();
});
this.getPrometheusData(this.lastHourDateObject);
this.getDetailsCardData();
}
ngOnDestroy() {
this.interval.unsubscribe();
}
getHealth() {
this.healthService.getMinimalHealth().subscribe((data: any) => {
this.healthData = data;
});
}
toggleAlertsWindow(type: string, isToggleButton: boolean = false) {
if (isToggleButton) {
this.showAlerts = !this.showAlerts;
this.flexHeight = !this.flexHeight;
} else if (
!this.showAlerts ||
(this.alertType === type && type !== 'danger') ||
(this.alertType !== 'warning' && type === 'danger')
) {
this.showAlerts = !this.showAlerts;
this.flexHeight = !this.flexHeight;
}
type === 'danger' ? (this.alertType = 'critical') : (this.alertType = type);
this.textClass = `text-${type}`;
this.borderClass = `border-${type}`;
}
getDetailsCardData() {
this.configService.get('fsid').subscribe((data) => {
this.detailsCardData.fsid = data['value'][0]['value'];
});
this.mgrModuleService.getConfig('orchestrator').subscribe((data) => {
const orchStr = data['orchestrator'];
this.detailsCardData.orchestrator = orchStr.charAt(0).toUpperCase() + orchStr.slice(1);
});
this.summaryService.subscribe((summary) => {
const version = summary.version.replace('ceph version ', '').split(' ');
this.detailsCardData.cephVersion =
version[0] + ' ' + version.slice(2, version.length).join(' ');
});
}
getCapacityCardData() {
this.osdSettingsService = this.osdService
.getOsdSettings()
.pipe(take(1))
.subscribe((data: any) => {
this.osdSettings = data;
});
this.capacityService = this.clusterService.getCapacity().subscribe((data: any) => {
this.capacity = data;
});
}
triggerPrometheusAlerts() {
this.prometheusService.ifAlertmanagerConfigured(() => {
this.isAlertmanagerConfigured = true;
this.prometheusService.getAlerts().subscribe((alerts) => {
this.alerts = alerts;
this.crticialActiveAlerts = alerts.filter(
(alert: AlertmanagerAlert) =>
alert.status.state === 'active' && alert.labels.severity === 'critical'
).length;
this.warningActiveAlerts = alerts.filter(
(alert: AlertmanagerAlert) =>
alert.status.state === 'active' && alert.labels.severity === 'warning'
).length;
});
});
}
getPrometheusData(selectedTime: any) {
if (this.timerGetPrometheusDataSub) {
this.timerGetPrometheusDataSub.unsubscribe();
}
this.timerGetPrometheusDataSub = timer(0, this.timerTime).subscribe(() => {
selectedTime = this.updateTimeStamp(selectedTime);
for (const queryName in queries) {
if (queries.hasOwnProperty(queryName)) {
const query = queries[queryName];
let interval = selectedTime.step;
if (query.includes('rate') && selectedTime.step < 20) {
interval = 20;
} else if (query.includes('rate')) {
interval = selectedTime.step * 2;
}
const intervalAdjustedQuery = query.replace(/\[(.*?)\]/g, `[${interval}s]`);
this.prometheusService
.getPrometheusData({
params: intervalAdjustedQuery,
start: selectedTime['start'],
end: selectedTime['end'],
step: selectedTime['step']
})
.subscribe((data: any) => {
if (data.result.length) {
this.queriesResults[queryName] = data.result[0].values;
}
});
}
}
});
}
private updateTimeStamp(selectedTime: any): any {
let formattedDate = {};
const date: number = selectedTime['start'] + this.timerTime / 1000;
const dateNow: number = selectedTime['end'] + this.timerTime / 1000;
formattedDate = {
start: date,
end: dateNow,
step: selectedTime['step']
};
return formattedDate;
}
}

View File

@ -0,0 +1,36 @@
import { TestBed } from '@angular/core/testing';
import { configureTestBed } from '~/testing/unit-test-helper';
import { PgCategoryService } from '../shared/pg-category.service';
import { PgSummaryPipe } from './pg-summary.pipe';
describe('OsdSummaryPipe', () => {
let pipe: PgSummaryPipe;
configureTestBed({
providers: [PgSummaryPipe, PgCategoryService]
});
beforeEach(() => {
pipe = TestBed.inject(PgSummaryPipe);
});
it('create an instance', () => {
expect(pipe).toBeTruthy();
});
it('tranforms value', () => {
const value = {
statuses: {
'active+clean': 241
},
pgs_per_osd: 241
};
expect(pipe.transform(value)).toEqual({
categoryPgAmount: {
clean: 241
},
total: 241
});
});
});

View File

@ -0,0 +1,27 @@
import { Pipe, PipeTransform } from '@angular/core';
import _ from 'lodash';
import { PgCategoryService } from '~/app/ceph/shared/pg-category.service';
@Pipe({
name: 'pgSummary'
})
export class PgSummaryPipe implements PipeTransform {
constructor(private pgCategoryService: PgCategoryService) {}
transform(value: any): any {
const categoryPgAmount: Record<string, number> = {};
let total = 0;
_.forEach(value.statuses, (pgAmount, pgStatesText) => {
const categoryType = this.pgCategoryService.getTypeByStates(pgStatesText);
if (_.isUndefined(categoryPgAmount[categoryType])) {
categoryPgAmount[categoryType] = 0;
}
categoryPgAmount[categoryType] += pgAmount;
total += pgAmount;
});
return {
categoryPgAmount,
total
};
}
}

View File

@ -1,8 +1,8 @@
<block-ui>
<cd-navigation>
<div class="container-fluid h-100"
[ngClass]="{'dashboard':isDashboardPage()} ">
<cd-context></cd-context>
[ngClass]="{'dashboard': (router.url == '/dashboard' || router.url == '/dashboard_3')}">
<cd-context></cd-context>
<cd-breadcrumbs></cd-breadcrumbs>
<router-outlet></router-outlet>
</div>

View File

@ -17,7 +17,7 @@ export class WorkbenchLayoutComponent implements OnInit, OnDestroy {
private subs = new Subscription();
constructor(
private router: Router,
public router: Router,
private summaryService: SummaryService,
private taskManagerService: TaskManagerService,
private faviconService: FaviconService
@ -32,8 +32,4 @@ export class WorkbenchLayoutComponent implements OnInit, OnDestroy {
ngOnDestroy() {
this.subs.unsubscribe();
}
isDashboardPage() {
return this.router.url === '/dashboard';
}
}

View File

@ -24,4 +24,8 @@ export class ClusterService {
{ headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } }
);
}
getCapacity() {
return this.http.get(`${this.baseURL}/capacity`, {});
}
}

View File

@ -24,6 +24,10 @@ export class PrometheusService {
constructor(private http: HttpClient, private settingsService: SettingsService) {}
getPrometheusData(params: any): any {
return this.http.get<any>(`${this.baseURL}/data`, { params });
}
ifAlertmanagerConfigured(fn: (value?: string) => void, elseFn?: () => void): void {
this.settingsService.ifSettingConfigured(this.settingsKey.alertmanager, fn, elseFn);
}

View File

@ -0,0 +1,10 @@
export enum Promqls {
USEDCAPACITY = 'ceph_cluster_total_used_bytes',
IPS = 'sum(rate(ceph_osd_op_w_in_bytes[$interval]))',
OPS = 'sum(rate(ceph_osd_op_r_out_bytes[$interval]))',
READLATENCY = 'avg_over_time(ceph_osd_apply_latency_ms[$interval])',
WRITELATENCY = 'avg_over_time(ceph_osd_commit_latency_ms[$interval])',
READCLIENTTHROUGHPUT = 'sum(rate(ceph_pool_rd_bytes[$interval]))',
WRITECLIENTTHROUGHPUT = 'sum(rate(ceph_pool_wr_bytes[$interval]))',
RECOVERYBYTES = 'sum(rate(ceph_osd_recovery_bytes[$interval]))'
}

View File

@ -0,0 +1,5 @@
export enum HealthIcon {
HEALTH_ERR = 'fa fa-exclamation-circle',
HEALTH_WARN = 'fa fa-exclamation-triangle',
HEALTH_OK = 'fa fa-check-circle'
}

View File

@ -21,6 +21,7 @@ export enum Icons {
analyse = 'fa fa-stethoscope', // Scrub
deepCheck = 'fa fa-cog', // Deep Scrub, Setting, Configuration
reweight = 'fa fa-balance-scale', // Reweight
up = 'fa fa-arrow-up', // Up
left = 'fa fa-arrow-left', // Mark out
right = 'fa fa-arrow-right', // Mark in
down = 'fa fa-arrow-down', // Mark Down
@ -34,6 +35,8 @@ export enum Icons {
info = 'fa fa-info', // Notification information
infoCircle = 'fa fa-info-circle', // Info on landing page
questionCircle = 'fa fa-question-circle-o',
danger = 'fa fa-exclamation-circle',
success = 'fa fa-check-circle',
check = 'fa fa-check', // Notification check
show = 'fa fa-eye', // Show
paragraph = 'fa fa-paragraph', // Silence Matcher - Attribute name

View File

@ -0,0 +1,5 @@
export interface DashboardDetails {
fsid?: string;
orchestrator?: string;
cephVersion?: string;
}

View File

@ -7,6 +7,7 @@ export class PrometheusAlertLabels {
class Annotations {
description: string;
summary: string;
}
class CommonAlertmanagerAlert {

View File

@ -0,0 +1,20 @@
import { HealthIconPipe } from './health-icon.pipe';
describe('HealthIconPipe', () => {
const pipe = new HealthIconPipe();
it('create an instance', () => {
expect(pipe).toBeTruthy();
});
it('transforms "HEALTH_OK"', () => {
expect(pipe.transform('HEALTH_OK')).toEqual('fa fa-check-circle');
});
it('transforms "HEALTH_WARN"', () => {
expect(pipe.transform('HEALTH_WARN')).toEqual('fa fa-exclamation-triangle');
});
it('transforms "HEALTH_ERR"', () => {
expect(pipe.transform('HEALTH_ERR')).toEqual('fa fa-exclamation-circle');
});
});

View File

@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core';
import { HealthIcon } from '../enum/health-icon.enum';
@Pipe({
name: 'healthIcon'
})
export class HealthIconPipe implements PipeTransform {
transform(value: string): string {
return Object.keys(HealthIcon).includes(value as HealthIcon) ? HealthIcon[value] : '';
}
}

View File

@ -0,0 +1,76 @@
import { TestBed } from '@angular/core/testing';
import { configureTestBed } from '~/testing/unit-test-helper';
import { MdsSummaryPipe } from './mds-summary.pipe';
describe('MdsSummaryPipe', () => {
let pipe: MdsSummaryPipe;
configureTestBed({
providers: [MdsSummaryPipe]
});
beforeEach(() => {
pipe = TestBed.inject(MdsSummaryPipe);
});
it('create an instance', () => {
expect(pipe).toBeTruthy();
});
it('transforms with 0 active and 2 standy', () => {
const payload = {
standbys: [{ name: 'a' }],
filesystems: [{ mdsmap: { info: [{ state: 'up:standby-replay' }] } }]
};
expect(pipe.transform(payload)).toEqual({
success: 0,
info: 2,
total: 2
});
});
it('transforms with 1 active and 1 standy', () => {
const payload = {
standbys: [{ name: 'b' }],
filesystems: [{ mdsmap: { info: [{ state: 'up:active', name: 'a' }] } }]
};
expect(pipe.transform(payload)).toEqual({
success: 1,
info: 1,
total: 2
});
});
it('transforms with 0 filesystems', () => {
const payload: Record<string, any> = {
standbys: [0],
filesystems: []
};
expect(pipe.transform(payload)).toEqual({
success: 0,
info: 0,
total: 0
});
});
it('transforms without filesystem', () => {
const payload = { standbys: [{ name: 'a' }] };
expect(pipe.transform(payload)).toEqual({
success: 0,
info: 1,
total: 1
});
});
it('transforms without value', () => {
expect(pipe.transform(undefined)).toEqual({
success: 0,
info: 0,
total: 0
});
});
});

View File

@ -0,0 +1,55 @@
import { Pipe, PipeTransform } from '@angular/core';
import _ from 'lodash';
@Pipe({
name: 'mdsSummary'
})
export class MdsSummaryPipe implements PipeTransform {
transform(value: any): any {
if (!value) {
return {
success: 0,
info: 0,
total: 0
};
}
let activeCount = 0;
let standbyCount = 0;
let standbys = 0;
let active = 0;
let standbyReplay = 0;
_.each(value.standbys, () => {
standbys += 1;
});
if (value.standbys && !value.filesystems) {
standbyCount = standbys;
activeCount = 0;
} else if (value.filesystems.length === 0) {
activeCount = 0;
} else {
_.each(value.filesystems, (fs) => {
_.each(fs.mdsmap.info, (mds) => {
if (mds.state === 'up:standby-replay') {
standbyReplay += 1;
} else {
active += 1;
}
});
});
activeCount = active;
standbyCount = standbys + standbyReplay;
}
const totalCount = activeCount + standbyCount;
const mdsSummary = {
success: activeCount,
info: standbyCount,
total: totalCount
};
return mdsSummary;
}
}

View File

@ -0,0 +1,38 @@
import { TestBed } from '@angular/core/testing';
import { configureTestBed } from '~/testing/unit-test-helper';
import { MgrSummaryPipe } from './mgr-summary.pipe';
describe('MgrSummaryPipe', () => {
let pipe: MgrSummaryPipe;
configureTestBed({
providers: [MgrSummaryPipe]
});
beforeEach(() => {
pipe = TestBed.inject(MgrSummaryPipe);
});
it('create an instance', () => {
expect(pipe).toBeTruthy();
});
it('transforms without value', () => {
expect(pipe.transform(undefined)).toEqual({
success: 0,
info: 0,
total: 0
});
});
it('transforms with 1 active and 2 standbys', () => {
const payload = {
active_name: 'x',
standbys: [{ name: 'y' }, { name: 'z' }]
};
const expected = { success: 1, info: 2, total: 3 };
expect(pipe.transform(payload)).toEqual(expected);
});
});

View File

@ -0,0 +1,37 @@
import { Pipe, PipeTransform } from '@angular/core';
import _ from 'lodash';
@Pipe({
name: 'mgrSummary'
})
export class MgrSummaryPipe implements PipeTransform {
transform(value: any): any {
if (!value) {
return {
success: 0,
info: 0,
total: 0
};
}
let activeCount: number;
const activeTitleText = _.isUndefined(value.active_name)
? ''
: `${$localize`active daemon`}: ${value.active_name}`;
// There is always one standbyreplay to replace active daemon, if active one is down
if (activeTitleText.length > 0) {
activeCount = 1;
}
const standbyCount = value.standbys.length;
const totalCount = activeCount + standbyCount;
const mgrSummary = {
success: activeCount,
info: standbyCount,
total: totalCount
};
return mgrSummary;
}
}

View File

@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { configureTestBed } from '~/testing/unit-test-helper';
import { OsdSummaryPipe } from './osd-summary.pipe';
describe('OsdSummaryPipe', () => {
let pipe: OsdSummaryPipe;
configureTestBed({
providers: [OsdSummaryPipe]
});
beforeEach(() => {
pipe = TestBed.inject(OsdSummaryPipe);
});
it('create an instance', () => {
expect(pipe).toBeTruthy();
});
it('transforms without value', () => {
expect(pipe.transform(undefined)).toBe('');
});
it('transforms having 3 osd with 3 up, 3 in, 0 down, 0 out', () => {
const value = {
osds: [
{ up: 1, in: 1, state: ['up', 'exists'] },
{ up: 1, in: 1, state: ['up', 'exists'] },
{ up: 1, in: 1, state: ['up', 'exists'] }
]
};
expect(pipe.transform(value)).toEqual({
total: 3,
down: 0,
out: 0,
up: 3,
in: 3,
nearfull: 0,
full: 0
});
});
});

View File

@ -0,0 +1,46 @@
import { Pipe, PipeTransform } from '@angular/core';
import _ from 'lodash';
@Pipe({
name: 'osdSummary'
})
export class OsdSummaryPipe implements PipeTransform {
transform(value: any): any {
if (!value) {
return '';
}
let inCount = 0;
let upCount = 0;
let nearFullCount = 0;
let fullCount = 0;
_.each(value.osds, (osd) => {
if (osd.in) {
inCount++;
}
if (osd.up) {
upCount++;
}
if (osd.state.includes('nearfull')) {
nearFullCount++;
}
if (osd.state.includes('full')) {
fullCount++;
}
});
const downCount = value.osds.length - upCount;
const outCount = value.osds.length - inCount;
const osdSummary = {
total: value.osds.length,
down: downCount,
out: outCount,
up: upCount,
in: inCount,
nearfull: nearFullCount,
full: fullCount
};
return osdSummary;
}
}

View File

@ -15,15 +15,19 @@ import { EmptyPipe } from './empty.pipe';
import { EncodeUriPipe } from './encode-uri.pipe';
import { FilterPipe } from './filter.pipe';
import { HealthColorPipe } from './health-color.pipe';
import { HealthIconPipe } from './health-icon.pipe';
import { HealthLabelPipe } from './health-label.pipe';
import { IopsPipe } from './iops.pipe';
import { IscsiBackstorePipe } from './iscsi-backstore.pipe';
import { JoinPipe } from './join.pipe';
import { LogPriorityPipe } from './log-priority.pipe';
import { MapPipe } from './map.pipe';
import { MdsSummaryPipe } from './mds-summary.pipe';
import { MgrSummaryPipe } from './mgr-summary.pipe';
import { MillisecondsPipe } from './milliseconds.pipe';
import { NotAvailablePipe } from './not-available.pipe';
import { OrdinalPipe } from './ordinal.pipe';
import { OsdSummaryPipe } from './osd-summary.pipe';
import { RbdConfigurationSourcePipe } from './rbd-configuration-source.pipe';
import { RelativeDatePipe } from './relative-date.pipe';
import { RoundPipe } from './round.pipe';
@ -64,7 +68,11 @@ import { UpperFirstPipe } from './upper-first.pipe';
MapPipe,
TruncatePipe,
SanitizeHtmlPipe,
SearchHighlightPipe
SearchHighlightPipe,
HealthIconPipe,
MgrSummaryPipe,
MdsSummaryPipe,
OsdSummaryPipe
],
exports: [
ArrayPipe,
@ -96,7 +104,11 @@ import { UpperFirstPipe } from './upper-first.pipe';
MapPipe,
TruncatePipe,
SanitizeHtmlPipe,
SearchHighlightPipe
SearchHighlightPipe,
HealthIconPipe,
MgrSummaryPipe,
MdsSummaryPipe,
OsdSummaryPipe
],
providers: [
ArrayPipe,
@ -123,7 +135,11 @@ import { UpperFirstPipe } from './upper-first.pipe';
DurationPipe,
MapPipe,
TruncatePipe,
SanitizeHtmlPipe
SanitizeHtmlPipe,
HealthIconPipe,
MgrSummaryPipe,
MdsSummaryPipe,
OsdSummaryPipe
]
})
export class PipesModule {}

View File

@ -67,7 +67,6 @@ $body-color-bright: $light !default;
$body-bg: $white !default;
$body-color: $gray-900 !default;
$body-bg-alt: $gray-200 !default;
// Health colors.
$health-color-error: $red !default;
$health-color-healthy: $green !default;
@ -82,11 +81,18 @@ $chart-color-yellow: #f6d173 !default;
$chart-color-green: $green !default;
$chart-color-gray: #ededed !default;
$chart-color-cyan: $primary-500 !default;
$chart-color-light-gray: #f0f0f0 !default;
$chart-color-slight-dark-gray: #d7d7d7 !default;
$chart-color-dark-gray: #afafaf !default;
$chart-color-cyan: #73c5c5 !default;
$chart-color-purple: #3c3d99 !default;
$chart-color-white: #fff !default;
$chart-color-center-text: #151515 !default;
$chart-color-center-text-description: #72767b !default;
$chart-color-tooltip-background: $black !default;
$chart-danger: #c9190b !default;
$chart-color-strong-blue: #0078c8 !default;
$chart-color-translucent-blue: #0096dc80 !default;
// Typography

View File

@ -2145,6 +2145,28 @@ paths:
summary: Update the cluster status
tags:
- Cluster
/api/cluster/capacity:
get:
parameters: []
responses:
'200':
content:
application/vnd.ceph.api.v1.0+json:
type: object
description: OK
'400':
description: Operation exception. Please check the response body for details.
'401':
description: Unauthenticated access. Please login first.
'403':
description: Unauthorized access. Please check your permissions.
'500':
description: Unexpected error. Please check the response body for the stack
trace.
security:
- jwt: []
tags:
- Cluster
/api/cluster/user:
get:
description: "\n Get list of ceph users and its respective data\n \
@ -7526,6 +7548,28 @@ paths:
- jwt: []
tags:
- Prometheus
/api/prometheus/data:
get:
parameters: []
responses:
'200':
content:
application/vnd.ceph.api.v1.0+json:
type: object
description: OK
'400':
description: Operation exception. Please check the response body for details.
'401':
description: Unauthenticated access. Please login first.
'403':
description: Unauthorized access. Please check your permissions.
'500':
description: Unexpected error. Please check the response body for the stack
trace.
security:
- jwt: []
tags:
- Prometheus
/api/prometheus/notifications:
get:
parameters: []

View File

@ -1,9 +1,16 @@
# -*- coding: utf-8 -*-
from enum import Enum
from typing import NamedTuple
from .. import mgr
class ClusterCapacity(NamedTuple):
total_avail_bytes: int
total_bytes: int
total_used_raw_bytes: int
class ClusterModel:
class Status(Enum):
@ -33,3 +40,10 @@ class ClusterModel:
If the status is not set, assume it is already fully functional.
"""
return cls(status=mgr.get_store('cluster/status', cls.Status.POST_INSTALLED.name))
@classmethod
def get_capacity(cls) -> ClusterCapacity:
df = mgr.get('df')
return ClusterCapacity(total_avail_bytes=df['stats']['total_avail_bytes'],
total_bytes=df['stats']['total_bytes'],
total_used_raw_bytes=df['stats']['total_used_raw_bytes'])._asdict()