diff --git a/src/pybind/mgr/dashboard/controllers/cluster.py b/src/pybind/mgr/dashboard/controllers/cluster.py index d8170e672e9929..5d776e063513dd 100644 --- a/src/pybind/mgr/dashboard/controllers/cluster.py +++ b/src/pybind/mgr/dashboard/controllers/cluster.py @@ -19,3 +19,7 @@ def list(self): parameters={'status': (str, 'Cluster Status')}) def singleton_set(self, status: str): ClusterModel(status).to_db() + + @RESTController.Collection('GET', 'capacity') + def get_capacity(self): + return ClusterModel.get_capacity() diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.html new file mode 100644 index 00000000000000..ba8176beab3b50 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.html @@ -0,0 +1,16 @@ +
+ + +
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.scss new file mode 100644 index 00000000000000..64e7a9822e22dd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.scss @@ -0,0 +1,22 @@ +@use './src/styles/chart-tooltip'; + +$canvas-width: 100%; +$canvas-height: 100%; + +.chart-container { + height: $canvas-height; + margin-left: auto; + margin-right: auto; + position: unset; + width: $canvas-width; +} + +.chart-canvas { + height: $canvas-height; + margin-left: auto; + margin-right: auto; + max-height: $canvas-height; + max-width: $canvas-width; + position: unset; + width: $canvas-width; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.spec.ts new file mode 100644 index 00000000000000..928e64dac8c817 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CssHelper } from '~/app/shared/classes/css-helper'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { DashboardPieComponent } from './dashboard-pie.component'; + +describe('DashboardPieComponent', () => { + let component: DashboardPieComponent; + let fixture: ComponentFixture; + + configureTestBed({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [DashboardPieComponent], + providers: [CssHelper] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DashboardPieComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.ts new file mode 100644 index 00000000000000..7154819f845af9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.ts @@ -0,0 +1,185 @@ +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`Capacity: ${chart.data.datasets[1].data[0]}%`, + 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, data: Record) => { + 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(); + } + } + ]; + + private prepareRawUsage(chart: Record, data: Record) { + 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]; + 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; + } + + constructor(private cssHelper: CssHelper, private dimlessBinary: DimlessBinaryPipe) {} + + ngOnInit() { + this.prepareRawUsage(this.chartConfig, this.data); + } + + ngOnChanges() { + this.prepareRawUsage(this.chartConfig, this.data); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard.module.ts index 27ed0f2d760549..34d41ddb31adbf 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard.module.ts @@ -9,6 +9,7 @@ import { ChartsModule } from 'ng2-charts'; import { SharedModule } from '~/app/shared/shared.module'; import { CephSharedModule } from '../shared/ceph-shared.module'; import { CardComponent } from './card/card.component'; +import { DashboardPieComponent } from './dashboard-pie/dashboard-pie.component'; import { DashboardComponent } from './dashboard/dashboard.component'; @NgModule({ @@ -24,6 +25,6 @@ import { DashboardComponent } from './dashboard/dashboard.component'; ReactiveFormsModule ], - declarations: [DashboardComponent, CardComponent] + declarations: [DashboardComponent, CardComponent, DashboardPieComponent] }) export class NewDashboardModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.html index c384687fbdad36..8316d59d974ec4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.html @@ -22,7 +22,14 @@ - Text + + + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.spec.ts index 1361a64eba0d19..b43d462878bf56 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.spec.ts @@ -1,13 +1,18 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; import { BehaviorSubject, of } from 'rxjs'; import { ConfigurationService } from '~/app/shared/api/configuration.service'; import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; +import { CssHelper } from '~/app/shared/classes/css-helper'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; import { SummaryService } from '~/app/shared/services/summary.service'; import { configureTestBed } from '~/testing/unit-test-helper'; import { CardComponent } from '../card/card.component'; +import { DashboardPieComponent } from '../dashboard-pie/dashboard-pie.component'; import { DashboardComponent } from './dashboard.component'; export class SummaryServiceMock { @@ -47,9 +52,14 @@ describe('CardComponent', () => { }; configureTestBed({ - imports: [HttpClientTestingModule], - declarations: [DashboardComponent, CardComponent], - providers: [{ provide: SummaryService, useClass: SummaryServiceMock }] + imports: [RouterTestingModule, HttpClientTestingModule], + declarations: [DashboardComponent, CardComponent, DashboardPieComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + CssHelper, + DimlessBinaryPipe, + { provide: SummaryService, useClass: SummaryServiceMock } + ] }); beforeEach(() => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.ts index 775c5eabe5ab28..e722fdf1e81b53 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.ts @@ -1,8 +1,19 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import _ from 'lodash'; +import { Observable, Subscription } from 'rxjs'; + +import { ClusterService } from '~/app/shared/api/cluster.service'; import { ConfigurationService } from '~/app/shared/api/configuration.service'; import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; +import { OsdService } from '~/app/shared/api/osd.service'; import { DashboardDetails } from '~/app/shared/models/cd-details'; +import { Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { + FeatureTogglesMap$, + FeatureTogglesService +} from '~/app/shared/services/feature-toggles.service'; import { SummaryService } from '~/app/shared/services/summary.service'; @Component({ @@ -10,17 +21,36 @@ import { SummaryService } from '~/app/shared/services/summary.service'; templateUrl: './dashboard.component.html', styleUrls: ['./dashboard.component.scss'] }) -export class DashboardComponent implements OnInit { +export class DashboardComponent implements OnInit, OnDestroy { detailsCardData: DashboardDetails = {}; - + osdSettings$: Observable; + interval = new Subscription(); + permissions: Permissions; + enabledFeature$: FeatureTogglesMap$; + color: string; + capacity$: Observable; constructor( private summaryService: SummaryService, private configService: ConfigurationService, - private mgrModuleService: MgrModuleService - ) {} + private mgrModuleService: MgrModuleService, + private clusterService: ClusterService, + private osdService: OsdService, + private authStorageService: AuthStorageService, + private featureToggles: FeatureTogglesService + ) { + this.permissions = this.authStorageService.getPermissions(); + this.enabledFeature$ = this.featureToggles.get(); + } ngOnInit() { this.getDetailsCardData(); + + this.osdSettings$ = this.osdService.getOsdSettings(); + this.capacity$ = this.clusterService.getCapacity(); + } + + ngOnDestroy() { + this.interval.unsubscribe(); } getDetailsCardData() { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts index 6b435d6ffed1dc..f5b8e4d7cc1185 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts @@ -24,4 +24,8 @@ export class ClusterService { { headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } } ); } + + getCapacity() { + return this.http.get(`${this.baseURL}/capacity`, {}); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/defaults/_bootstrap-defaults.scss b/src/pybind/mgr/dashboard/frontend/src/styles/defaults/_bootstrap-defaults.scss index d4a3d5f86bec7a..fa8de6de7c3db6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/defaults/_bootstrap-defaults.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/defaults/_bootstrap-defaults.scss @@ -50,7 +50,6 @@ $body-color-bright: $light !default; $body-bg: $white !default; $body-color: $gray-900 !default; $body-bg-alt: $gray-200 !default; - // Health colors. $health-color-error: #f00 !default; $health-color-healthy: $green !default; @@ -63,8 +62,12 @@ $chart-color-orange: #ef9234 !default; $chart-color-yellow: #f6d173 !default; $chart-color-green: #7cc674 !default; $chart-color-gray: #ededed !default; +$chart-color-light-gray: #f0f0f0 !default; +$chart-color-slight-dark-gray: #d7d7d7 !default; +$chart-color-dark-gray: #afafaf !default; $chart-color-cyan: #73c5c5 !default; $chart-color-purple: #3c3d99 !default; +$chart-color-white: #fff !default; $chart-color-center-text: #151515 !default; $chart-color-center-text-description: #72767b !default; $chart-color-tooltip-background: $black !default; diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 8655a57364612e..3fec589fbe8696 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -2137,6 +2137,28 @@ paths: summary: Update the cluster status tags: - Cluster + /api/cluster/capacity: + get: + parameters: [] + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: OK + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + tags: + - Cluster /api/cluster_conf: get: parameters: [] diff --git a/src/pybind/mgr/dashboard/services/cluster.py b/src/pybind/mgr/dashboard/services/cluster.py index a057f24381f78a..fbb00bc7370703 100644 --- a/src/pybind/mgr/dashboard/services/cluster.py +++ b/src/pybind/mgr/dashboard/services/cluster.py @@ -1,9 +1,16 @@ # -*- coding: utf-8 -*- from enum import Enum +from typing import NamedTuple from .. import mgr +class ClusterCapacity(NamedTuple): + total_avail_bytes: int + total_bytes: int + total_used_raw_bytes: int + + class ClusterModel: class Status(Enum): @@ -33,3 +40,10 @@ def from_db(cls): If the status is not set, assume it is already fully functional. """ return cls(status=mgr.get_store('cluster/status', cls.Status.POST_INSTALLED.name)) + + @classmethod + def get_capacity(cls) -> ClusterCapacity: + df = mgr.get('df') + return ClusterCapacity(total_avail_bytes=df['stats']['total_avail_bytes'], + total_bytes=df['stats']['total_bytes'], + total_used_raw_bytes=df['stats']['total_used_raw_bytes'])._asdict()