Overview of the Dashboard Landing Page
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-Displays overall cluster status, performance, and capacity metrics. Shows instant
-feedback for changes in the cluster and provides easy access to subpages of the
-dashboard.
+The landing page of Ceph Dashboard serves as the home page and features metrics
+such as the overall cluster status, performance, and capacity. It provides real-time
+updates on any changes in the cluster and allows quick access to other sections of the dashboard.
+
+.. image:: dashboard-landing-page.png
+
+
+.. note::
+ You can change the landing page to the previous version from:
+ ``Cluster >> Manager Modules >> Dashboard >> Edit``.
+ Editing the ``FEATURE_TOGGLE_DASHBOARD`` option will change the landing page, from one view to another.
+
+ Note that the previous version of the landing page will be disabled in future releases.
+
+.. _dashboard-landing-page-details:
+
+Details
+"""""""
+Provides an overview of the cluster configuration, displaying various critical aspects of the cluster.
+
+.. image:: details-card.png
.. _dashboard-landing-page-status:
Status
""""""
+Provides a visual indication of cluster health, and displays cluster alerts grouped by severity.
-* **Cluster Status**: Displays overall cluster health. In case of any error it
- displays a short description of the error and provides a link to the logs.
-* **Hosts**: Displays the total number of hosts associated to the cluster and
- links to a subpage that lists and describes each.
-* **Monitors**: Displays mons and their quorum status and
- open sessions. Links to a subpage that lists and describes each.
-* **OSDs**: Displays object storage daemons (ceph-osds) and
- the numbers of OSDs running (up), in service
- (in), and out of the cluster (out). Provides links to
- subpages providing a list of all OSDs and related management actions.
-* **Managers**: Displays active and standby Ceph Manager
- daemons (ceph-mgr).
-* **Object Gateway**: Displays active object gateways (RGWs) and
- provides links to subpages that list all object gateway daemons.
-* **Metadata Servers**: Displays active and standby CephFS metadata
- service daemons (ceph-mds).
-* **iSCSI Gateways**: Display iSCSI gateways available,
- active (up), and inactive (down). Provides a link to a subpage
- showing a list of all iSCSI Gateways.
+.. image:: status-card-open.png
.. _dashboard-landing-page-capacity:
Capacity
""""""""
+* **Used**: Displays the used capacity out of the total physical capacity providedd by storage nodes (OSDs)
+* **Warning**: Displays the `nearfull` threshold of the OSDs
+* **Danger**: Displays the `full` threshold of the OSDs
+
+.. image:: capacity-card.png
-* **Raw Capacity**: Displays the capacity used out of the total
- physical capacity provided by storage nodes (OSDs).
-* **Objects**: Displays the number and status of RADOS objects
- including the percentages of healthy, misplaced, degraded, and unfound
- objects.
-* **PG Status**: Displays the total number of placement groups and
- their status, including the percentage clean, working,
- warning, and unknown.
-* **Pools**: Displays pools and links to a subpage listing details.
-* **PGs per OSD**: Displays the number of placement groups assigned to
- object storage daemons.
+.. _dashboard-landing-page-inventory:
+
+Inventory
+"""""""""
+An inventory for all assets within the cluster.
+Provides direct access to subpages of the dashboard from each item of this card.
+
+.. image:: inventory-card.png
.. _dashboard-landing-page-performance:
-Performance
-"""""""""""
+Cluster Utilization
+"""""""""""""""""""
+* **Used Capacity**: Total capacity used of the cluster. The maximum value of the chart is the maximum capacity of the cluster.
+* **IOPS (Input/Output Operations Per Second)**: Number of read and write operations.
+* **Latency**: Amount of time that it takes to process a read or a write request.
+* **Client Throughput**: Amount of data that clients read or write to the cluster.
+* **Recovery Throughput**: Amount of recovery data that clients read or write to the cluster.
+
-* **Client READ/Write**: Displays an overview of
- client input and output operations.
-* **Client Throughput**: Displays the data transfer rates to and from Ceph clients.
-* **Recovery throughput**: Displays rate of cluster healing and balancing operations.
-* **Scrubbing**: Displays light and deep scrub status.
+.. image:: cluster-utilization-card.png
Supported Browsers
^^^^^^^^^^^^^^^^^^
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 { DeprecatedDashboardComponent } from './ceph/dashboard/dashboard/dashboard.component';
-import { DashboardComponent } from './ceph/new-dashboard/dashboard/dashboard.component';
+import { DashboardComponent } from './ceph/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: DeprecatedDashboardComponent },
- { path: 'dashboard_3', component: DashboardComponent },
+ { path: 'dashboard', 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,
--- /dev/null
+<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>
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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;
+ }
+ }
+}
--- /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 { 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();
+ });
+});
--- /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
+<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>
--- /dev/null
+@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;
+}
--- /dev/null
+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();
+ });
+});
--- /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
+<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>
--- /dev/null
+select#timepicker {
+ border: 0;
+}
+
+.timeSelector {
+ position: absolute;
+ right: 18px;
+ top: 20px;
+ width: 12rem;
+}
--- /dev/null
+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();
+ });
+});
--- /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, 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 { DashboardV3Component } from './dashboard/dashboard-v3.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: [
+ DashboardV3Component,
+ CardComponent,
+ DashboardPieComponent,
+ CardRowComponent,
+ PgSummaryPipe,
+ DashboardAreaChartComponent,
+ DashboardTimeSelectorComponent
+ ],
+
+ exports: [DashboardV3Component]
+})
+export class DashboardV3Module {}
--- /dev/null
+<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 && (prometheusAlertService.activeCriticalAlerts || prometheusAlertService.activeWarningAlerts)">
+ <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="prometheusAlertService?.activeCriticalAlerts > 0">
+ <i [ngClass]="[icons.danger]"></i>
+ <span>{{ prometheusAlertService.activeCriticalAlerts }}</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="prometheusAlertService?.activeWarningAlerts > 0">
+ <i [ngClass]="[icons.infoCircle]"></i>
+ <span>{{ prometheusAlertService.activeWarningAlerts }}</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>
--- /dev/null
+.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;
+}
--- /dev/null
+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 { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.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 { DashboardV3Component } from './dashboard-v3.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: DashboardV3Component;
+ let fixture: ComponentFixture<DashboardV3Component>;
+ 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: [
+ DashboardV3Component,
+ CardComponent,
+ DashboardPieComponent,
+ CardRowComponent,
+ PgSummaryPipe
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ { provide: SummaryService, useClass: SummaryServiceMock },
+ {
+ provide: PrometheusAlertService,
+ useValue: {
+ activeCriticalAlerts: 2,
+ activeWarningAlerts: 1
+ }
+ },
+ 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(DashboardV3Component);
+ 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 warningAlerts = fixture.debugElement.query(By.css('button[id=warningAlerts] span'));
+
+ const dangerAlerts = fixture.debugElement.query(By.css('button[id=dangerAlerts] span'));
+
+ expect(warningAlerts.nativeElement.textContent).toBe('1');
+ expect(dangerAlerts.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', () => {
+ spyOn(TestBed.inject(PrometheusAlertService), 'activeCriticalAlerts').and.returnValue(0);
+ spyOn(TestBed.inject(PrometheusAlertService), 'activeWarningAlerts').and.returnValue(0);
+ fixture.detectChanges();
+
+ const warningAlerts = fixture.debugElement.query(By.css('button[id=warningAlerts]'));
+
+ const dangerAlerts = fixture.debugElement.query(By.css('button[id=dangerAlerts]'));
+
+ expect(warningAlerts).toBe(null);
+ expect(dangerAlerts).toBe(null);
+ });
+
+ describe('features disabled', () => {
+ beforeEach(() => {
+ fakeFeatureTogglesService.and.returnValue(
+ of({
+ rbd: false,
+ mirroring: false,
+ iscsi: false,
+ cephfs: false,
+ rgw: false
+ })
+ );
+ fixture = TestBed.createComponent(DashboardV3Component);
+ 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();
+ });
+ });
+});
--- /dev/null
+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';
+import { PrometheusListHelper } from '~/app/shared/helpers/prometheus-list-helper';
+import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
+
+@Component({
+ selector: 'cd-dashboard-v3',
+ templateUrl: './dashboard-v3.component.html',
+ styleUrls: ['./dashboard-v3.component.scss']
+})
+export class DashboardV3Component extends PrometheusListHelper 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[]>;
+
+ icons = Icons;
+ showAlerts = false;
+ flexHeight = true;
+ simplebar = {
+ autoHide: false
+ };
+ textClass: string;
+ borderClass: string;
+ alertType: string;
+ alerts: AlertmanagerAlert[];
+ 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,
+ public prometheusAlertService: PrometheusAlertService
+ ) {
+ super(prometheusService);
+ this.permissions = this.authStorageService.getPermissions();
+ this.enabledFeature$ = this.featureToggles.get();
+ }
+
+ ngOnInit() {
+ super.ngOnInit();
+ this.interval = this.refreshIntervalService.intervalData$.subscribe(() => {
+ this.getHealth();
+ this.getCapacityCardData();
+ });
+ this.getPrometheusData(this.lastHourDateObject);
+ this.getDetailsCardData();
+ }
+
+ ngOnDestroy() {
+ this.interval.unsubscribe();
+ if (this.timerGetPrometheusDataSub) {
+ this.timerGetPrometheusDataSub.unsubscribe();
+ }
+ }
+
+ getHealth() {
+ this.healthService.getMinimalHealth().subscribe((data: any) => {
+ this.healthData = data;
+ });
+ }
+
+ toggleAlertsWindow(type: string, isToggleButton: boolean = false) {
+ this.triggerPrometheusAlerts();
+ 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.prometheusService.getAlerts().subscribe((alerts) => {
+ this.alerts = alerts;
+ });
+ });
+ }
+
+ getPrometheusData(selectedTime: any) {
+ this.prometheusService.ifPrometheusConfigured(() => {
+ 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;
+ }
+}
--- /dev/null
+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
+ });
+ });
+});
--- /dev/null
+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
+ };
+ }
+}
import { ChartsModule } from 'ng2-charts';
import { SharedModule } from '~/app/shared/shared.module';
+import { DashboardV3Module } from '../dashboard-v3/dashboard-v3.module';
import { CephSharedModule } from '../shared/ceph-shared.module';
import { FeedbackComponent } from '../shared/feedback/feedback.component';
-import { DeprecatedDashboardComponent } from './dashboard/dashboard.component';
+import { DashboardComponent } 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';
RouterModule,
NgbPopoverModule,
FormsModule,
- ReactiveFormsModule
+ ReactiveFormsModule,
+ DashboardV3Module
],
declarations: [
HealthComponent,
- DeprecatedDashboardComponent,
+ DashboardComponent,
MonSummaryPipe,
OsdSummaryPipe,
MgrSummaryPipe,
-<main aria-label="Dashboard" >
+<main aria-label="Dashboard">
<a href="#main"
class="sr-only">skip to content</a>
- <cd-refresh-selector></cd-refresh-selector>
+ <ng-container *ngIf="(enabledFeature$ | async)?.dashboard === false; else dashboardV3"
+ class="main-padding">
+ <cd-refresh-selector></cd-refresh-selector>
- <ng-container *ngIf="hasGrafana">
- <nav ngbNav
- #nav="ngbNav"
- class="nav-tabs">
- <ng-container ngbNavItem>
- <a ngbNavLink
- i18n>Health</a>
- <ng-template ngbNavContent>
- <cd-health></cd-health>
- </ng-template>
- </ng-container>
- <ng-container ngbNavItem>
- <a ngbNavLink
- i18n>Statistics</a>
- <ng-template ngbNavContent>
- </ng-template>
- </ng-container>
-
- </nav>
-
- <div [ngbNavOutlet]="nav"></div>
+ <cd-health id="main"></cd-health>
</ng-container>
- <cd-health id="main"
- *ngIf="!hasGrafana"></cd-health>
+ <ng-template #dashboardV3>
+ <cd-dashboard-v3></cd-dashboard-v3>
+ </ng-template>
</main>
-main {
+main:has(cd-health) {
padding-top: 20px;
}
+import { HttpClientTestingModule } from '@angular/common/http/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { FeatureTogglesService } from '~/app/shared/services/feature-toggles.service';
import { configureTestBed } from '~/testing/unit-test-helper';
-import { DeprecatedDashboardComponent } from './dashboard.component';
+import { DashboardComponent } from './dashboard.component';
describe('DashboardComponent', () => {
- let component: DeprecatedDashboardComponent;
- let fixture: ComponentFixture<DeprecatedDashboardComponent>;
+ let component: DashboardComponent;
+ let fixture: ComponentFixture<DashboardComponent>;
configureTestBed({
- imports: [NgbNavModule],
- declarations: [DeprecatedDashboardComponent],
+ imports: [NgbNavModule, HttpClientTestingModule],
+ declarations: [DashboardComponent],
+ providers: [FeatureTogglesService],
schemas: [NO_ERRORS_SCHEMA]
});
beforeEach(() => {
- fixture = TestBed.createComponent(DeprecatedDashboardComponent);
+ fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
import { Component } from '@angular/core';
+import { Observable } from 'rxjs';
+import { FeatureTogglesService } from '~/app/shared/services/feature-toggles.service';
@Component({
selector: 'cd-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
-export class DeprecatedDashboardComponent {
- hasGrafana = false; // TODO: Temporary var, remove when grafana is implemented
+export class DashboardComponent {
+ enabledFeature$: Observable<Object>;
+
+ constructor(private featureToggles: FeatureTogglesService) {
+ this.enabledFeature$ = this.featureToggles.get();
+ }
}
+++ /dev/null
-<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>
+++ /dev/null
-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();
- });
-});
+++ /dev/null
-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;
- }
- }
-}
+++ /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 { 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();
- });
-});
+++ /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
-<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>
+++ /dev/null
-@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;
-}
+++ /dev/null
-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();
- });
-});
+++ /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
-<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>
+++ /dev/null
-select#timepicker {
- border: 0;
-}
-
-.timeSelector {
- position: absolute;
- right: 18px;
- top: 20px;
- width: 12rem;
-}
+++ /dev/null
-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();
- });
-});
+++ /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, 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 {}
+++ /dev/null
-<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 && (prometheusAlertService.activeCriticalAlerts || prometheusAlertService.activeWarningAlerts)">
- <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="prometheusAlertService?.activeCriticalAlerts > 0">
- <i [ngClass]="[icons.danger]"></i>
- <span>{{ prometheusAlertService.activeCriticalAlerts }}</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="prometheusAlertService?.activeWarningAlerts > 0">
- <i [ngClass]="[icons.infoCircle]"></i>
- <span>{{ prometheusAlertService.activeWarningAlerts }}</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>
+++ /dev/null
-.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;
-}
+++ /dev/null
-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 { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.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 },
- {
- provide: PrometheusAlertService,
- useValue: {
- activeCriticalAlerts: 2,
- activeWarningAlerts: 1
- }
- },
- 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 warningAlerts = fixture.debugElement.query(By.css('button[id=warningAlerts] span'));
-
- const dangerAlerts = fixture.debugElement.query(By.css('button[id=dangerAlerts] span'));
-
- expect(warningAlerts.nativeElement.textContent).toBe('1');
- expect(dangerAlerts.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', () => {
- spyOn(TestBed.inject(PrometheusAlertService), 'activeCriticalAlerts').and.returnValue(0);
- spyOn(TestBed.inject(PrometheusAlertService), 'activeWarningAlerts').and.returnValue(0);
- fixture.detectChanges();
-
- const warningAlerts = fixture.debugElement.query(By.css('button[id=warningAlerts]'));
-
- const dangerAlerts = fixture.debugElement.query(By.css('button[id=dangerAlerts]'));
-
- expect(warningAlerts).toBe(null);
- expect(dangerAlerts).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();
- });
- });
-});
+++ /dev/null
-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';
-import { PrometheusListHelper } from '~/app/shared/helpers/prometheus-list-helper';
-import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
-
-@Component({
- selector: 'cd-dashboard',
- templateUrl: './dashboard.component.html',
- styleUrls: ['./dashboard.component.scss']
-})
-export class DashboardComponent extends PrometheusListHelper 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[]>;
-
- icons = Icons;
- showAlerts = false;
- flexHeight = true;
- simplebar = {
- autoHide: false
- };
- textClass: string;
- borderClass: string;
- alertType: string;
- alerts: AlertmanagerAlert[];
- 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,
- public prometheusAlertService: PrometheusAlertService
- ) {
- super(prometheusService);
- this.permissions = this.authStorageService.getPermissions();
- this.enabledFeature$ = this.featureToggles.get();
- }
-
- ngOnInit() {
- super.ngOnInit();
- this.interval = this.refreshIntervalService.intervalData$.subscribe(() => {
- this.getHealth();
- 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) {
- this.triggerPrometheusAlerts();
- 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.prometheusService.getAlerts().subscribe((alerts) => {
- this.alerts = alerts;
- });
- });
- }
-
- getPrometheusData(selectedTime: any) {
- this.prometheusService.ifPrometheusConfigured(() => {
- 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;
- }
-}
+++ /dev/null
-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
- });
- });
-});
+++ /dev/null
-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
- };
- }
-}
color: vv.$gray-200;
}
+ .no-hover:hover {
+ background-color: vv.$secondary !important;
+ }
+
@media (min-width: vv.$screen-md-min) {
.cd-navbar-utility {
border-bottom: 0;
cephfs = true;
rgw = true;
nfs = true;
+ dashboardV3 = true;
}
export type Features = keyof FeatureTogglesMap;
export type FeatureTogglesMap$ = Observable<FeatureTogglesMap>;
cephfs:
description: ''
type: boolean
+ dashboard:
+ description: ''
+ type: boolean
iscsi:
description: ''
type: boolean
- cephfs
- rgw
- nfs
+ - dashboard
type: object
description: OK
'400':
CEPHFS = 'cephfs'
RGW = 'rgw'
NFS = 'nfs'
+ DASHBOARD = 'dashboard'
PREDISABLED_FEATURES = set() # type: Set[str]
"iscsi": (bool, ''),
"cephfs": (bool, ''),
"rgw": (bool, ''),
- "nfs": (bool, '')
+ "nfs": (bool, ''),
+ "dashboard": (bool, '')
}
@APIRouter('/feature_toggles')