]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add info to Pools table 25489/head
authoralfonsomthd <almartin@redhat.com>
Fri, 14 Dec 2018 16:21:42 +0000 (17:21 +0100)
committeralfonsomthd <almartin@redhat.com>
Mon, 17 Dec 2018 13:18:57 +0000 (14:18 +0100)
- Column 'Placement Groups' renamed to 'Pg Status':
It shows PG states.
- Created 'CephSharedModule' for shared services within ceph module.
- Created PgCategoryService & PgCategory model (logic encapsulation).
- Color consistency: PG chart (landing page) and cell text color are similar.

Fixes: https://tracker.ceph.com/issues/36740
Signed-off-by: Alfonso Martínez <almartin@redhat.com>
18 files changed:
src/pybind/mgr/dashboard/controllers/pool.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/dashboard.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status-style.pipe.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status-style.pipe.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status.pipe.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status.pipe.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf

index cdd9a7110d45d1684feb76d544fa4f35c032caf4..a0aaee78e16a0fbcf73af5748b22128c13a094e7 100644 (file)
@@ -54,7 +54,7 @@ class Pool(RESTController):
 
         return [self._serialize_pool(pool, attrs) for pool in pools]
 
-    def list(self, attrs=None, stats=False):
+    def list(self, attrs=None, stats=True):
         return self._pool_list(attrs, stats)
 
     def _get(self, pool_name, attrs=None, stats=False):
index e0e4dfe43380045a8bce20242c7da07dd8ca0741..7d751f0362bc65dba664df0a05dec308c45d2909 100644 (file)
@@ -7,6 +7,7 @@ import { PopoverModule } from 'ngx-bootstrap/popover';
 import { TabsModule } from 'ngx-bootstrap/tabs';
 
 import { SharedModule } from '../../shared/shared.module';
+import { CephSharedModule } from '../shared/ceph-shared.module';
 import { DashboardComponent } from './dashboard/dashboard.component';
 import { HealthPieComponent } from './health-pie/health-pie.component';
 import { HealthComponent } from './health/health.component';
@@ -17,11 +18,10 @@ import { MdsSummaryPipe } from './mds-summary.pipe';
 import { MgrSummaryPipe } from './mgr-summary.pipe';
 import { MonSummaryPipe } from './mon-summary.pipe';
 import { OsdSummaryPipe } from './osd-summary.pipe';
-import { PgStatusStylePipe } from './pg-status-style.pipe';
-import { PgStatusPipe } from './pg-status.pipe';
 
 @NgModule({
   imports: [
+    CephSharedModule,
     CommonModule,
     TabsModule.forRoot(),
     SharedModule,
@@ -37,9 +37,7 @@ import { PgStatusPipe } from './pg-status.pipe';
     OsdSummaryPipe,
     LogColorPipe,
     MgrSummaryPipe,
-    PgStatusPipe,
     MdsSummaryPipe,
-    PgStatusStylePipe,
     HealthPieComponent,
     InfoCardComponent,
     InfoGroupComponent
index 847121617ae2e1905505ec78a0184bf8d572b47a..bbbbe2b8ed09b57e014e416393716245127fc318 100644 (file)
@@ -12,12 +12,13 @@ import { HealthService } from '../../../shared/api/health.service';
 import { Permissions } from '../../../shared/models/permissions';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { SharedModule } from '../../../shared/shared.module';
+import { PgCategoryService } from '../../shared/pg-category.service';
+import { HealthPieColor } from '../health-pie/health-pie-color.enum';
+import { HealthPieComponent } from '../health-pie/health-pie.component';
 import { MdsSummaryPipe } from '../mds-summary.pipe';
 import { MgrSummaryPipe } from '../mgr-summary.pipe';
 import { MonSummaryPipe } from '../mon-summary.pipe';
 import { OsdSummaryPipe } from '../osd-summary.pipe';
-import { PgStatusStylePipe } from '../pg-status-style.pipe';
-import { PgStatusPipe } from '../pg-status.pipe';
 import { HealthComponent } from './health.component';
 
 describe('HealthComponent', () => {
@@ -49,21 +50,25 @@ describe('HealthComponent', () => {
     imports: [SharedModule, HttpClientTestingModule, PopoverModule.forRoot()],
     declarations: [
       HealthComponent,
+      HealthPieComponent,
       MonSummaryPipe,
       OsdSummaryPipe,
       MdsSummaryPipe,
-      MgrSummaryPipe,
-      PgStatusStylePipe,
-      PgStatusPipe
+      MgrSummaryPipe
     ],
     schemas: [NO_ERRORS_SCHEMA],
-    providers: [i18nProviders, { provide: AuthStorageService, useValue: fakeAuthStorageService }]
+    providers: [
+      i18nProviders,
+      { provide: AuthStorageService, useValue: fakeAuthStorageService },
+      PgCategoryService
+    ]
   });
 
   beforeEach(() => {
     fixture = TestBed.createComponent(HealthComponent);
     component = fixture.componentInstance;
     getHealthSpy = spyOn(TestBed.get(HealthService), 'getMinimalHealth');
+    getHealthSpy.and.returnValue(of(healthPayload));
   });
 
   it('should create', () => {
@@ -71,7 +76,6 @@ describe('HealthComponent', () => {
   });
 
   it('should render all info groups and all info cards', () => {
-    getHealthSpy.and.returnValue(of(healthPayload));
     fixture.detectChanges();
 
     const infoGroups = fixture.debugElement.nativeElement.querySelectorAll('cd-info-group');
@@ -148,7 +152,6 @@ describe('HealthComponent', () => {
   });
 
   it('should render "Cluster Status" card text that is not clickable', () => {
-    getHealthSpy.and.returnValue(of(healthPayload));
     fixture.detectChanges();
 
     const clusterStatusCard = fixture.debugElement.query(
@@ -177,4 +180,70 @@ describe('HealthComponent', () => {
     const clickableContent = clusterStatusCard.query(By.css('.info-card-content-clickable'));
     expect(clickableContent.nativeElement.textContent).toEqual(` ${payload.health.status} `);
   });
+
+  it('event binding "prepareReadWriteRatio" is called', () => {
+    const prepareReadWriteRatio = spyOn(component, 'prepareReadWriteRatio');
+
+    const payload = _.cloneDeep(healthPayload);
+    payload.client_perf['read_op_per_sec'] = 1;
+    payload.client_perf['write_op_per_sec'] = 1;
+    getHealthSpy.and.returnValue(of(payload));
+    fixture.detectChanges();
+
+    expect(prepareReadWriteRatio).toHaveBeenCalled();
+  });
+
+  it('event binding "prepareRawUsage" is called', () => {
+    const prepareRawUsage = spyOn(component, 'prepareRawUsage');
+
+    fixture.detectChanges();
+
+    expect(prepareRawUsage).toHaveBeenCalled();
+  });
+
+  it('event binding "preparePgStatus" is called', () => {
+    const preparePgStatus = spyOn(component, 'preparePgStatus');
+
+    fixture.detectChanges();
+
+    expect(preparePgStatus).toHaveBeenCalled();
+  });
+
+  describe('preparePgStatus', () => {
+    const expectedChart = (data: number[]) => ({
+      colors: [
+        {
+          backgroundColor: [
+            HealthPieColor.SHADE_GREEN_CYAN,
+            HealthPieColor.MEDIUM_DARK_SHADE_CYAN_BLUE,
+            HealthPieColor.LIGHT_SHADE_BROWN,
+            HealthPieColor.MEDIUM_LIGHT_SHADE_PINK_RED
+          ]
+        }
+      ],
+      labels: ['Clean', 'Working', 'Warning', 'Unknown'],
+      dataset: [{ data: data }]
+    });
+
+    it('gets no data', () => {
+      const chart = { dataset: [{}] };
+      component.preparePgStatus(chart, { pg_info: {} });
+      expect(chart).toEqual(expectedChart([undefined, undefined, undefined, undefined]));
+    });
+
+    it('gets data from all categories', () => {
+      const chart = { dataset: [{}] };
+      component.preparePgStatus(chart, {
+        pg_info: {
+          statuses: {
+            'clean+active+scrubbing+nonMappedState': 4,
+            'clean+active+scrubbing': 2,
+            'clean+active': 1,
+            'clean+active+scrubbing+down': 3
+          }
+        }
+      });
+      expect(chart).toEqual(expectedChart([1, 2, 3, 4]));
+    });
+  });
 });
index 31ce19f494aaa5aae1fd9882f547f8b5f3922566..bb78e22efa390c071d83dd74401a1e10f38eb3b2 100644 (file)
@@ -6,6 +6,8 @@ import * as _ from 'lodash';
 import { HealthService } from '../../../shared/api/health.service';
 import { Permissions } from '../../../shared/models/permissions';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { PgCategoryService } from '../../shared/pg-category.service';
+import { HealthPieColor } from '../health-pie/health-pie-color.enum';
 
 @Component({
   selector: 'cd-health',
@@ -20,7 +22,8 @@ export class HealthComponent implements OnInit, OnDestroy {
   constructor(
     private healthService: HealthService,
     private i18n: I18n,
-    private authStorageService: AuthStorageService
+    private authStorageService: AuthStorageService,
+    private pgCategoryService: PgCategoryService
   ) {
     this.permissions = this.authStorageService.getPermissions();
   }
@@ -76,65 +79,35 @@ export class HealthComponent implements OnInit, OnDestroy {
   }
 
   preparePgStatus(chart, data) {
-    const pgCategoryClean = this.i18n('Clean');
-    const pgCategoryCleanStates = ['active', 'clean'];
-    const pgCategoryWarning = this.i18n('Warning');
-    const pgCategoryWarningStates = [
-      'backfill_toofull',
-      'backfill_unfound',
-      'down',
-      'incomplete',
-      'inconsistent',
-      'recovery_toofull',
-      'recovery_unfound',
-      'remapped',
-      'snaptrim_error',
-      'stale',
-      'undersized'
+    const categoryPgAmount = {};
+    chart.labels = [
+      this.i18n('Clean'),
+      this.i18n('Working'),
+      this.i18n('Warning'),
+      this.i18n('Unknown')
     ];
-    const pgCategoryUnknown = this.i18n('Unknown');
-    const pgCategoryWorking = this.i18n('Working');
-    const pgCategoryWorkingStates = [
-      'activating',
-      'backfill_wait',
-      'backfilling',
-      'creating',
-      'deep',
-      'degraded',
-      'forced_backfill',
-      'forced_recovery',
-      'peering',
-      'peered',
-      'recovering',
-      'recovery_wait',
-      'repair',
-      'scrubbing',
-      'snaptrim',
-      'snaptrim_wait'
+    chart.colors = [
+      {
+        backgroundColor: [
+          HealthPieColor.SHADE_GREEN_CYAN,
+          HealthPieColor.MEDIUM_DARK_SHADE_CYAN_BLUE,
+          HealthPieColor.LIGHT_SHADE_BROWN,
+          HealthPieColor.MEDIUM_LIGHT_SHADE_PINK_RED
+        ]
+      }
     ];
-    let totalPgClean = 0;
-    let totalPgWarning = 0;
-    let totalPgUnknown = 0;
-    let totalPgWorking = 0;
 
     _.forEach(data.pg_info.statuses, (pgAmount, pgStatesText) => {
-      const pgStates = pgStatesText.split('+');
-      const isWarning = _.intersection(pgCategoryWarningStates, pgStates).length > 0;
-      const pgWorkingStates = _.intersection(pgCategoryWorkingStates, pgStates);
-      const pgCleanStates = _.intersection(pgCategoryCleanStates, pgStates);
-
-      if (isWarning) {
-        totalPgWarning += pgAmount;
-      } else if (pgStates.length > pgCleanStates.length + pgWorkingStates.length) {
-        totalPgUnknown += pgAmount;
-      } else if (pgWorkingStates.length > 0) {
-        totalPgWorking = pgAmount;
-      } else {
-        totalPgClean += pgAmount;
+      const categoryType = this.pgCategoryService.getTypeByStates(pgStatesText);
+
+      if (_.isUndefined(categoryPgAmount[categoryType])) {
+        categoryPgAmount[categoryType] = 0;
       }
+      categoryPgAmount[categoryType] += pgAmount;
     });
 
-    chart.labels = [pgCategoryWarning, pgCategoryClean, pgCategoryUnknown, pgCategoryWorking];
-    chart.dataset[0].data = [totalPgWarning, totalPgClean, totalPgUnknown, totalPgWorking];
+    chart.dataset[0].data = this.pgCategoryService
+      .getAllTypes()
+      .map((categoryType) => categoryPgAmount[categoryType]);
   }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status-style.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status-style.pipe.spec.ts
deleted file mode 100644 (file)
index e5c5a09..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-import { PgStatusStylePipe } from './pg-status-style.pipe';
-
-describe('PgStatusStylePipe', () => {
-  const pipe = new PgStatusStylePipe();
-
-  it('create an instance', () => {
-    expect(pipe).toBeTruthy();
-  });
-
-  it('transforms with pg status error', () => {
-    const value = { 'incomplete+clean': 8 };
-    expect(pipe.transform(value)).toEqual({ color: '#FF0000' });
-  });
-
-  it('transforms with pg status warning', () => {
-    const value = { active: 8 };
-    expect(pipe.transform(value)).toEqual({ color: '#FFC200' });
-  });
-
-  it('transforms with pg status other', () => {
-    const value = { 'active+clean': 8 };
-    expect(pipe.transform(value)).toEqual({ color: '#00BB00' });
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status-style.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status-style.pipe.ts
deleted file mode 100644 (file)
index 4e9afab..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Pipe, PipeTransform } from '@angular/core';
-import * as _ from 'lodash';
-
-@Pipe({
-  name: 'pgStatusStyle'
-})
-export class PgStatusStylePipe implements PipeTransform {
-  transform(pgStatus: any, args?: any): any {
-    let warning = false;
-    let error = false;
-
-    _.each(pgStatus, (value, state) => {
-      if (
-        state.includes('inconsistent') ||
-        state.includes('incomplete') ||
-        !state.includes('active')
-      ) {
-        error = true;
-      }
-
-      if (
-        state !== 'active+clean' &&
-        state !== 'active+clean+scrubbing' &&
-        state !== 'active+clean+scrubbing+deep'
-      ) {
-        warning = true;
-      }
-    });
-
-    if (error) {
-      return { color: '#FF0000' };
-    }
-
-    if (warning) {
-      return { color: '#FFC200' };
-    }
-
-    return { color: '#00BB00' };
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status.pipe.spec.ts
deleted file mode 100644 (file)
index 8b65547..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import { PgStatusPipe } from './pg-status.pipe';
-
-describe('PgStatusPipe', () => {
-  const pipe = new PgStatusPipe();
-
-  it('create an instance', () => {
-    expect(pipe).toBeTruthy();
-  });
-
-  it('transforms with 1 status', () => {
-    const value = { 'active+clean': 8 };
-    expect(pipe.transform(value)).toBe('8 active+clean');
-  });
-
-  it('transforms with 2 status', () => {
-    const value = { active: 8, incomplete: 8 };
-    expect(pipe.transform(value)).toBe('8 active, 8 incomplete');
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/pg-status.pipe.ts
deleted file mode 100644 (file)
index 5c6c7b3..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Pipe, PipeTransform } from '@angular/core';
-import * as _ from 'lodash';
-
-@Pipe({
-  name: 'pgStatus'
-})
-export class PgStatusPipe implements PipeTransform {
-  transform(pgStatus: any, args?: any): any {
-    const strings = [];
-    _.each(pgStatus, (count, state) => {
-      strings.push(count + ' ' + state);
-    });
-
-    return strings.join(', ');
-  }
-}
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..e27c9b301e0ef05b24ba09394dba4e11dd9b0eeb 100644 (file)
@@ -0,0 +1,17 @@
+@import '../../../../defaults';
+
+::ng-deep .pg-clean {
+  color: $color-bright-green;
+}
+
+::ng-deep .pg-working {
+  color: $color-blue;
+}
+
+::ng-deep .pg-warning {
+  color: $color-bright-yellow;
+}
+
+::ng-deep .pg-unknown {
+  color: $color-solid-red;
+}
index c24138b98cfe2d7e83832640ffdd3559d850ffd7..1e71c63b2eeb101f446760d488203365c9ed50be 100644 (file)
@@ -14,6 +14,7 @@ import { ExecutingTask } from '../../../shared/models/executing-task';
 import { SummaryService } from '../../../shared/services/summary.service';
 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
 import { SharedModule } from '../../../shared/shared.module';
+import { PgCategoryService } from '../../shared/pg-category.service';
 import { Pool } from '../pool';
 import { PoolListComponent } from './pool-list.component';
 
@@ -31,7 +32,7 @@ describe('PoolListComponent', () => {
       TabsModule.forRoot(),
       HttpClientTestingModule
     ],
-    providers: i18nProviders
+    providers: [i18nProviders, PgCategoryService]
   });
 
   beforeEach(() => {
@@ -166,4 +167,77 @@ describe('PoolListComponent', () => {
       expect(component.pools[2].cdExecuting).toBeFalsy();
     });
   });
+
+  describe('getPgStatusCellClass', () => {
+    const testMethod = (value, expected) =>
+      expect(component.getPgStatusCellClass({ row: '', column: '', value: value })).toEqual({
+        'text-right': true,
+        [expected]: true
+      });
+
+    it('pg-clean', () => {
+      testMethod('8 active+clean', 'pg-clean');
+    });
+
+    it('pg-working', () => {
+      testMethod('  8 active+clean+scrubbing+deep, 255 active+clean  ', 'pg-working');
+    });
+
+    it('pg-warning', () => {
+      testMethod('8 active+clean+scrubbing+down', 'pg-warning');
+      testMethod('8 active+clean+scrubbing+down+nonMappedState', 'pg-warning');
+    });
+
+    it('pg-unknown', () => {
+      testMethod('8 active+clean+scrubbing+nonMappedState', 'pg-unknown');
+      testMethod('8 ', 'pg-unknown');
+      testMethod('', 'pg-unknown');
+    });
+  });
+
+  describe('transformPoolsData', () => {
+    it('transforms pools data correctly', () => {
+      const pools = [{ stats: { rate: 0 }, pg_status: { 'active+clean': 8, down: 2 } }];
+      const expected = [{ pg_status: '8 active+clean, 2 down' }];
+
+      expect(component.transformPoolsData(pools)).toEqual(expected);
+    });
+
+    it('transforms empty pools data correctly', () => {
+      const pools = undefined;
+      const expected = undefined;
+
+      expect(component.transformPoolsData(pools)).toEqual(expected);
+    });
+  });
+
+  describe('transformPgStatus', () => {
+    it('returns ststus groups correctly', () => {
+      const pgStatus = { 'active+clean': 8 };
+      const expected = '8 active+clean';
+
+      expect(component.transformPgStatus(pgStatus)).toEqual(expected);
+    });
+
+    it('returns separated status groups', () => {
+      const pgStatus = { 'active+clean': 8, down: 2 };
+      const expected = '8 active+clean, 2 down';
+
+      expect(component.transformPgStatus(pgStatus)).toEqual(expected);
+    });
+
+    it('returns separated statuses correctly', () => {
+      const pgStatus = { active: 8, down: 2 };
+      const expected = '8 active, 2 down';
+
+      expect(component.transformPgStatus(pgStatus)).toEqual(expected);
+    });
+
+    it('returns empty string', () => {
+      const pgStatus = undefined;
+      const expected = '';
+
+      expect(component.transformPgStatus(pgStatus)).toEqual(expected);
+    });
+  });
 });
index 9cbcefcf675d0bff158d866e27f961b5cea8942e..bf58ce5226390b25662c556172096bcbe337e6a8 100644 (file)
@@ -1,6 +1,7 @@
 import { Component, OnInit, ViewChild } from '@angular/core';
 
 import { I18n } from '@ngx-translate/i18n-polyfill';
+import * as _ from 'lodash';
 import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal';
 
 import { PoolService } from '../../../shared/api/pool.service';
@@ -17,6 +18,7 @@ import { Permissions } from '../../../shared/models/permissions';
 import { AuthStorageService } from '../../../shared/services/auth-storage.service';
 import { TaskListService } from '../../../shared/services/task-list.service';
 import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { PgCategoryService } from '../../shared/pg-category.service';
 import { Pool } from '../pool';
 
 @Component({
@@ -44,7 +46,8 @@ export class PoolListComponent implements OnInit {
     private authStorageService: AuthStorageService,
     private taskListService: TaskListService,
     private modalService: BsModalService,
-    private i18n: I18n
+    private i18n: I18n,
+    private pgCategoryService: PgCategoryService
   ) {
     this.permissions = this.authStorageService.getPermissions();
     this.tableActions = [
@@ -85,10 +88,12 @@ export class PoolListComponent implements OnInit {
         flexGrow: 3
       },
       {
-        prop: 'pg_placement_num',
-        name: this.i18n('Placement Groups'),
+        prop: 'pg_status',
+        name: this.i18n('PG Status'),
         flexGrow: 1,
-        cellClass: 'text-right'
+        cellClass: ({ row, column, value }): any => {
+          return this.getPgStatusCellClass({ row, column, value });
+        }
       },
       {
         prop: 'size',
@@ -119,7 +124,7 @@ export class PoolListComponent implements OnInit {
     this.taskListService.init(
       () => this.poolService.getList(),
       undefined,
-      (pools) => (this.pools = pools),
+      (pools) => (this.pools = this.transformPoolsData(pools)),
       () => {
         this.table.reset(); // Disable loading indicator.
         this.viewCacheStatusList = [{ status: ViewCacheStatus.ValueException }];
@@ -147,4 +152,29 @@ export class PoolListComponent implements OnInit {
       }
     });
   }
+
+  getPgStatusCellClass({ row, column, value }): object {
+    return {
+      'text-right': true,
+      [`pg-${this.pgCategoryService.getTypeByStates(value)}`]: true
+    };
+  }
+
+  transformPoolsData(pools: any) {
+    _.map(pools, (pool: object) => {
+      delete pool['stats'];
+      pool['pg_status'] = this.transformPgStatus(pool['pg_status']);
+    });
+
+    return pools;
+  }
+
+  transformPgStatus(pgStatus: any): string {
+    const strings = [];
+    _.forEach(pgStatus, (count, state) => {
+      strings.push(`${count} ${state}`);
+    });
+
+    return strings.join(', ');
+  }
 }
index d086dd05cc2f2bf8fa734dd1bbbae13cd8985089..46c4fd073ff43163dfbf0a63eeed0836d6fd86ba 100644 (file)
@@ -9,12 +9,14 @@ import { TabsModule } from 'ngx-bootstrap/tabs';
 
 import { ServicesModule } from '../../shared/services/services.module';
 import { SharedModule } from '../../shared/shared.module';
+import { CephSharedModule } from '../shared/ceph-shared.module';
 import { ErasureCodeProfileFormComponent } from './erasure-code-profile-form/erasure-code-profile-form.component';
 import { PoolFormComponent } from './pool-form/pool-form.component';
 import { PoolListComponent } from './pool-list/pool-list.component';
 
 @NgModule({
   imports: [
+    CephSharedModule,
     CommonModule,
     TabsModule,
     PopoverModule.forRoot(),
index cccab137439a41ca9a2b3ede51f1653dc9f41da6..4241565d8b585105136ded2591cad4b69d2f2567 100644 (file)
@@ -60,6 +60,7 @@ export class Pool {
   last_change: string;
   min_write_recency_for_promote: number;
   read_tier: number;
+  pg_status: string;
 
   constructor(name) {
     this.pool_name = name;
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/ceph-shared.module.ts
new file mode 100644 (file)
index 0000000..aaf0ddc
--- /dev/null
@@ -0,0 +1,7 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+@NgModule({
+  imports: [CommonModule]
+})
+export class CephSharedModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.model.ts
new file mode 100644 (file)
index 0000000..12fda77
--- /dev/null
@@ -0,0 +1,71 @@
+export class PgCategory {
+  static readonly CATEGORY_CLEAN = 'clean';
+  static readonly CATEGORY_WORKING = 'working';
+  static readonly CATEGORY_WARNING = 'warning';
+  static readonly CATEGORY_UNKNOWN = 'unknown';
+  static readonly VALID_CATEGORIES = [
+    PgCategory.CATEGORY_CLEAN,
+    PgCategory.CATEGORY_WORKING,
+    PgCategory.CATEGORY_WARNING,
+    PgCategory.CATEGORY_UNKNOWN
+  ];
+
+  states: string[];
+
+  constructor(public type: string) {
+    if (!this.isValidType()) {
+      throw new Error('Wrong placement group category type');
+    }
+
+    this.setTypeStates();
+  }
+
+  private isValidType() {
+    return PgCategory.VALID_CATEGORIES.includes(this.type);
+  }
+
+  private setTypeStates() {
+    switch (this.type) {
+      case PgCategory.CATEGORY_CLEAN:
+        this.states = ['active', 'clean'];
+        break;
+      case PgCategory.CATEGORY_WORKING:
+        this.states = [
+          'activating',
+          'backfill_wait',
+          'backfilling',
+          'creating',
+          'deep',
+          'degraded',
+          'forced_backfill',
+          'forced_recovery',
+          'peering',
+          'peered',
+          'recovering',
+          'recovery_wait',
+          'repair',
+          'scrubbing',
+          'snaptrim',
+          'snaptrim_wait'
+        ];
+        break;
+      case PgCategory.CATEGORY_WARNING:
+        this.states = [
+          'backfill_toofull',
+          'backfill_unfound',
+          'down',
+          'incomplete',
+          'inconsistent',
+          'recovery_toofull',
+          'recovery_unfound',
+          'remapped',
+          'snaptrim_error',
+          'stale',
+          'undersized'
+        ];
+        break;
+      default:
+        this.states = [];
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.spec.ts
new file mode 100644 (file)
index 0000000..5a71c20
--- /dev/null
@@ -0,0 +1,55 @@
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '../../../testing/unit-test-helper';
+import { PgCategory } from './pg-category.model';
+import { PgCategoryService } from './pg-category.service';
+
+describe('PgCategoryService', () => {
+  let service: PgCategoryService;
+
+  configureTestBed({
+    providers: [PgCategoryService]
+  });
+
+  beforeEach(() => {
+    service = TestBed.get(PgCategoryService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('returns all category types', () => {
+    const categoryTypes = service.getAllTypes();
+
+    expect(categoryTypes).toEqual(PgCategory.VALID_CATEGORIES);
+  });
+
+  describe('getTypeByStates', () => {
+    const testMethod = (value, expected) =>
+      expect(service.getTypeByStates(value)).toEqual(expected);
+
+    it(PgCategory.CATEGORY_CLEAN, () => {
+      testMethod('clean', PgCategory.CATEGORY_CLEAN);
+    });
+
+    it(PgCategory.CATEGORY_WORKING, () => {
+      testMethod('clean+scrubbing', PgCategory.CATEGORY_WORKING);
+      testMethod(
+        '  8 active+clean+scrubbing+deep, 255 active+clean  ',
+        PgCategory.CATEGORY_WORKING
+      );
+    });
+
+    it(PgCategory.CATEGORY_WARNING, () => {
+      testMethod('clean+scrubbing+down', PgCategory.CATEGORY_WARNING);
+      testMethod('clean+scrubbing+down+nonMappedState', PgCategory.CATEGORY_WARNING);
+    });
+
+    it(PgCategory.CATEGORY_UNKNOWN, () => {
+      testMethod('clean+scrubbing+nonMappedState', PgCategory.CATEGORY_UNKNOWN);
+      testMethod('nonMappedState', PgCategory.CATEGORY_UNKNOWN);
+      testMethod('', PgCategory.CATEGORY_UNKNOWN);
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/shared/pg-category.service.ts
new file mode 100644 (file)
index 0000000..79c2bcc
--- /dev/null
@@ -0,0 +1,63 @@
+import { Injectable } from '@angular/core';
+
+import * as _ from 'lodash';
+
+import { CephSharedModule } from './ceph-shared.module';
+import { PgCategory } from './pg-category.model';
+
+@Injectable({
+  providedIn: CephSharedModule
+})
+export class PgCategoryService {
+  private categories: object;
+
+  constructor() {
+    this.categories = this.createCategories();
+  }
+
+  getAllTypes() {
+    return PgCategory.VALID_CATEGORIES;
+  }
+
+  getTypeByStates(pgStatesText: string): string {
+    const pgStates = this.getPgStatesFromText(pgStatesText);
+
+    if (pgStates.length === 0) {
+      return PgCategory.CATEGORY_UNKNOWN;
+    }
+
+    const intersections = _.zipObject(
+      PgCategory.VALID_CATEGORIES,
+      PgCategory.VALID_CATEGORIES.map(
+        (category) => _.intersection(this.categories[category].states, pgStates).length
+      )
+    );
+
+    if (intersections[PgCategory.CATEGORY_WARNING] > 0) {
+      return PgCategory.CATEGORY_WARNING;
+    }
+
+    const pgWorkingStates = intersections[PgCategory.CATEGORY_WORKING];
+    if (pgStates.length > intersections[PgCategory.CATEGORY_CLEAN] + pgWorkingStates) {
+      return PgCategory.CATEGORY_UNKNOWN;
+    }
+
+    return pgWorkingStates ? PgCategory.CATEGORY_WORKING : PgCategory.CATEGORY_CLEAN;
+  }
+
+  private createCategories() {
+    return _.zipObject(
+      PgCategory.VALID_CATEGORIES,
+      PgCategory.VALID_CATEGORIES.map((category) => new PgCategory(category))
+    );
+  }
+
+  private getPgStatesFromText(pgStatesText) {
+    const pgStates = pgStatesText
+      .replace(/[^a-z]+/g, ' ')
+      .trim()
+      .split(' ');
+
+    return _.uniq(pgStates);
+  }
+}
index d308849f5fc5bf4973b4e9a96f245a8bc06c66ae..10caf93c6230d63c460efe87a94cda91445fc931 100644 (file)
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="a8059e31694578c1b0344a76a345357dd60e8f01" datatype="html">
-        <source>Warning</source>
+      <trans-unit id="0054f5460090d6dde385e8f099d598df5d28cf54" datatype="html">
+        <source>Working</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/ceph/dashboard/health/health.component.ts</context>
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="e5d8bb389c702588877f039d72178f219453a72d" datatype="html">
-        <source>Unknown</source>
+      <trans-unit id="a8059e31694578c1b0344a76a345357dd60e8f01" datatype="html">
+        <source>Warning</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/ceph/dashboard/health/health.component.ts</context>
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="0054f5460090d6dde385e8f099d598df5d28cf54" datatype="html">
-        <source>Working</source>
+      <trans-unit id="e5d8bb389c702588877f039d72178f219453a72d" datatype="html">
+        <source>Unknown</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/ceph/dashboard/health/health.component.ts</context>
           <context context-type="linenumber">1</context>
           <context context-type="linenumber">1</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="1d1174ec0fd282599b9feb00fc7a770e12ee7294" datatype="html">
-        <source>Placement Groups</source>
-        <context-group purpose="location">
-          <context context-type="sourcefile">src/app/ceph/pool/pool-list/pool-list.component.ts</context>
-          <context context-type="linenumber">1</context>
-        </context-group>
-      </trans-unit>
       <trans-unit id="112d356ad6846959ca4aa5ec9a1f0d9d473b24ec" datatype="html">
         <source>Replica Size</source>
         <context-group purpose="location">