mirror of
https://github.com/ceph/ceph
synced 2025-01-04 02:02:36 +00:00
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:
commit
7e5b2542ab
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -0,0 +1,5 @@
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-evenly;
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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>
|
@ -0,0 +1,9 @@
|
||||
.center-text {
|
||||
margin-top: 1.2vw;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart {
|
||||
height: 8vh;
|
||||
margin-top: 15px;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
@ -0,0 +1,10 @@
|
||||
select#timepicker {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.timeSelector {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 20px;
|
||||
width: 12rem;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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>
|
@ -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;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
@ -24,4 +24,8 @@ export class ClusterService {
|
||||
{ headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } }
|
||||
);
|
||||
}
|
||||
|
||||
getCapacity() {
|
||||
return this.http.get(`${this.baseURL}/capacity`, {});
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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]))'
|
||||
}
|
@ -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'
|
||||
}
|
@ -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
|
||||
|
@ -0,0 +1,5 @@
|
||||
export interface DashboardDetails {
|
||||
fsid?: string;
|
||||
orchestrator?: string;
|
||||
cephVersion?: string;
|
||||
}
|
@ -7,6 +7,7 @@ export class PrometheusAlertLabels {
|
||||
|
||||
class Annotations {
|
||||
description: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
class CommonAlertmanagerAlert {
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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] : '';
|
||||
}
|
||||
}
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 {}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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: []
|
||||
|
@ -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()
|
||||
|
Loading…
Reference in New Issue
Block a user