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()