]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: capacity card
authorPedro Gonzalez Gomez <pegonzal@redhat.com>
Mon, 1 Aug 2022 20:17:29 +0000 (22:17 +0200)
committerPedro Gonzalez Gomez <pegonzal@redhat.com>
Tue, 20 Dec 2022 07:30:14 +0000 (08:30 +0100)
Signed-off-by: Pedro Gonzalez Gomez <pegonzal@redhat.com>
13 files changed:
src/pybind/mgr/dashboard/controllers/cluster.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard-pie/dashboard-pie.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts
src/pybind/mgr/dashboard/frontend/src/styles/defaults/_bootstrap-defaults.scss
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/cluster.py

index d8170e672e9929fc7a80b381f352cc603cf4eb56..5d776e063513ddea8a7c4fbbaf58829410c2e224 100644 (file)
@@ -19,3 +19,7 @@ class Cluster(RESTController):
                  parameters={'status': (str, 'Cluster Status')})
     def singleton_set(self, status: str):
         ClusterModel(status).to_db()
+
+    @RESTController.Collection('GET', 'capacity')
+    def get_capacity(self):
+        return ClusterModel.get_capacity()
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 (file)
index 0000000..ba8176b
--- /dev/null
@@ -0,0 +1,16 @@
+<div class="chart-container">
+  <canvas baseChart
+          #chartCanvas
+          [datasets]="chartConfig.dataset"
+          [chartType]="chartConfig.chartType"
+          [options]="chartConfig.options"
+          [labels]="chartConfig.labels"
+          [colors]="chartConfig.colors"
+          [plugins]="doughnutChartPlugins"
+          class="chart-canvas">
+  </canvas>
+  <div class="chartjs-tooltip"
+       #chartTooltip>
+    <table></table>
+  </div>
+</div>
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 (file)
index 0000000..64e7a98
--- /dev/null
@@ -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 (file)
index 0000000..892913d
--- /dev/null
@@ -0,0 +1,27 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CssHelper } from '~/app/shared/classes/css-helper';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { DashboardPieComponent } from './dashboard-pie.component';
+
+describe('DashboardPieComponent', () => {
+  let component: DashboardPieComponent;
+  let fixture: ComponentFixture<DashboardPieComponent>;
+
+  configureTestBed({
+    schemas: [NO_ERRORS_SCHEMA],
+    declarations: [DashboardPieComponent],
+    providers: [CssHelper, DimlessBinaryPipe]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(DashboardPieComponent);
+    component = fixture.componentInstance;
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
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 (file)
index 0000000..d2e4ed0
--- /dev/null
@@ -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<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];
+    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;
+  }
+}
index 27ed0f2d760549c02101fe6bf6295ac1ddae4554..34d41ddb31adbf1cb23e5db3daf13173f82eabf0 100644 (file)
@@ -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 {}
index c384687fbdad36c41a699229a240792ab8dd4fb0..8316d59d974ec4854d0cf08f19949598f7be6379 100644 (file)
     <cd-card title="Capacity"
              i18n-title
              class="col-sm-3 px-3">
-             Text
+      <ng-container *ngIf="{osdSettings: osdSettings$ | async, capacity: capacity$ | async} as values">
+        <ng-container *ngIf="values.osdSettings && values.capacity">
+          <cd-dashboard-pie [data]="{max: values.capacity.total_bytes, current: values.capacity.total_used_raw_bytes}"
+                            [lowThreshold]="values.osdSettings.nearfull_ratio"
+                            [highThreshold]="values.osdSettings.full_ratio">
+          </cd-dashboard-pie>
+        </ng-container>
+      </ng-container>
     </cd-card>
   </div>
 
index 1361a64eba0d191825d39401b6a6ff0408e21995..b43d462878bf562a50613025987906ebe6e0c2b4 100644 (file)
@@ -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(() => {
index 775c5eabe5ab289e333adf231825e07010e984cd..e722fdf1e81b53ff162aad29874416efaa33148c 100644 (file)
@@ -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<any>;
+  interval = new Subscription();
+  permissions: Permissions;
+  enabledFeature$: FeatureTogglesMap$;
+  color: string;
+  capacity$: Observable<any>;
   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() {
index 6b435d6ffed1dc15a7c5a4de19e9a2d650a7acfe..f5b8e4d7cc11851852bc8409d78a266724494612 100644 (file)
@@ -24,4 +24,8 @@ export class ClusterService {
       { headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } }
     );
   }
+
+  getCapacity() {
+    return this.http.get(`${this.baseURL}/capacity`, {});
+  }
 }
index 941f639a363c14a022aa109a3fe0959b53df1977..f871b4ff80f8ed6f84e87405925f418536761645 100644 (file)
@@ -67,7 +67,6 @@ $body-color-bright: $light !default;
 $body-bg: $white !default;
 $body-color: $gray-900 !default;
 $body-bg-alt: $gray-200 !default;
-
 // Health colors.
 $health-color-error: $red !default;
 $health-color-healthy: $green !default;
@@ -82,7 +81,12 @@ $chart-color-yellow: #f6d173 !default;
 $chart-color-green: $green !default;
 $chart-color-gray: #ededed !default;
 $chart-color-cyan: $primary-500 !default;
+$chart-color-light-gray: #f0f0f0 !default;
+$chart-color-slight-dark-gray: #d7d7d7 !default;
+$chart-color-dark-gray: #afafaf !default;
+$chart-color-cyan: #73c5c5 !default;
 $chart-color-purple: #3c3d99 !default;
+$chart-color-white: #fff !default;
 $chart-color-center-text: #151515 !default;
 $chart-color-center-text-description: #72767b !default;
 $chart-color-tooltip-background: $black !default;
index 03614c1425fb19d47f386555a68337dc0ffd72c7..d86899c9c53d3423a9a99d320b2660ed0fccb5f1 100644 (file)
@@ -2168,6 +2168,28 @@ paths:
       summary: Get Ceph Users
       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: []
index a057f24381f78a406979c88de03651aa39fb473f..fbb00bc7370703c5a3095aa0b470e98fc79fd0e9 100644 (file)
@@ -1,9 +1,16 @@
 # -*- coding: utf-8 -*-
 from enum import Enum
+from typing import NamedTuple
 
 from .. import mgr
 
 
+class ClusterCapacity(NamedTuple):
+    total_avail_bytes: int
+    total_bytes: int
+    total_used_raw_bytes: int
+
+
 class ClusterModel:
 
     class Status(Enum):
@@ -33,3 +40,10 @@ class ClusterModel:
         If the status is not set, assume it is already fully functional.
         """
         return cls(status=mgr.get_store('cluster/status', cls.Status.POST_INSTALLED.name))
+
+    @classmethod
+    def get_capacity(cls) -> ClusterCapacity:
+        df = mgr.get('df')
+        return ClusterCapacity(total_avail_bytes=df['stats']['total_avail_bytes'],
+                               total_bytes=df['stats']['total_bytes'],
+                               total_used_raw_bytes=df['stats']['total_used_raw_bytes'])._asdict()