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';
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
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';
CommonModule,
ClusterModule,
DashboardModule,
+ NewDashboardModule,
PerformanceCounterModule,
CephfsModule,
NfsModule,
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';
declarations: [
HealthComponent,
- DashboardComponent,
+ DeprecatedDashboardComponent,
MonSummaryPipe,
OsdSummaryPipe,
MgrSummaryPipe,
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();
});
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
-export class DashboardComponent {
+export class DeprecatedDashboardComponent {
hasGrafana = false; // TODO: Temporary var, remove when grafana is implemented
}
--- /dev/null
+<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>
--- /dev/null
+.card-body {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+}
--- /dev/null
+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);
+ });
+});
--- /dev/null
+import { Component, Input } from '@angular/core';
+
+@Component({
+ selector: 'cd-card',
+ templateUrl: './card.component.html',
+ styleUrls: ['./card.component.scss']
+})
+export class CardComponent {
+ @Input()
+ title: string;
+}
--- /dev/null
+<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>
--- /dev/null
+.center-text {
+ margin-top: 1.2vw;
+ position: relative;
+}
+
+.chart {
+ height: 8vh;
+ margin-top: 15px;
+}
--- /dev/null
+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();
+ }
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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;
+ }
+}
--- /dev/null
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
+
+import { NgbNavModule, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap';
+import { ChartsModule } from 'ng2-charts';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { CephSharedModule } from '../shared/ceph-shared.module';
+import { CardComponent } from './card/card.component';
+import { DashboardComponent } from './dashboard/dashboard.component';
+
+@NgModule({
+ imports: [
+ CephSharedModule,
+ CommonModule,
+ NgbNavModule,
+ SharedModule,
+ ChartsModule,
+ RouterModule,
+ NgbPopoverModule,
+ FormsModule,
+ ReactiveFormsModule
+ ],
+
+ declarations: [DashboardComponent, CardComponent]
+})
+export class NewDashboardModule {}
--- /dev/null
+<div class="container-fluid">
+ <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="{osdSettings: osdSettings$ | async, capacity: capacity$ | async} as values">
+ <ng-container *ngIf="values.osdSettings && values.capacity">
+ <cd-dashboard-pie [data]="{max: values.capacity.total_bytes, current: values.capacity.total_used_raw_bytes}"
+ [lowThreshold]="values.osdSettings.nearfull_ratio"
+ [highThreshold]="values.osdSettings.full_ratio">
+ </cd-dashboard-pie>
+ </ng-container>
+ </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="Capacity 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>
--- /dev/null
+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;
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CardComponent } from '../card/card.component';
+import { DashboardComponent } from './dashboard.component';
+
+describe('CardComponent', () => {
+ let component: DashboardComponent;
+ let fixture: ComponentFixture<DashboardComponent>;
+
+ configureTestBed({
+ imports: [RouterTestingModule],
+ declarations: [DashboardComponent, CardComponent]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(DashboardComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render all cards', () => {
+ const dashboardCards = fixture.debugElement.nativeElement.querySelectorAll('cd-card');
+ expect(dashboardCards.length).toBe(5);
+ });
+});
--- /dev/null
+import { Component } from '@angular/core';
+
+@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;
+ }
+}
<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>
private subs = new Subscription();
constructor(
- private router: Router,
+ public router: Router,
private summaryService: SummaryService,
private taskManagerService: TaskManagerService,
private faviconService: FaviconService
ngOnDestroy() {
this.subs.unsubscribe();
}
-
- isDashboardPage() {
- return this.router.url === '/dashboard';
- }
}
--- /dev/null
+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]))'
+}