* Landing Page 'Objects' card now is a chart that shows more info about objects.
* Fix: Dimless/dimlessBinary pipe applied to amount displayed in
chart slice tooltip body (if shown).
* Refactoring: simplified way of setting chart initial config
via 'config' @Input; erased redundant @Inputs.
Updated chart component default config (for the sake of simplicity).
Fixes: https://tracker.ceph.com/issues/39613
Signed-off-by: Alfonso MartÃnez <almartin@redhat.com>
class HealthTest(DashboardTestCase):
CEPHFS = True
+ __pg_info_schema = JObj({
+ 'object_stats': JObj({
+ 'num_objects': int,
+ 'num_object_copies': int,
+ 'num_objects_degraded': int,
+ 'num_objects_misplaced': int,
+ 'num_objects_unfound': int
+ }),
+ 'pgs_per_osd': float,
+ 'statuses': JObj({}, allow_unknown=True, unknown_schema=int)
+ })
+
def test_minimal_health(self):
data = self._get('/api/health/minimal')
self.assertStatus(200)
'stats': JObj({
'total_avail_bytes': int,
'total_bytes': int,
- 'total_objects': int,
'total_used_raw_bytes': int,
})
}),
'up': int,
})),
}),
- 'pg_info': JObj({
- 'pgs_per_osd': float,
- 'statuses': JObj({}, allow_unknown=True, unknown_schema=int)
- }),
+ 'pg_info': self.__pg_info_schema,
'pools': JList(JLeaf(dict)),
'rgw': int,
'scrub_status': str
'stats': JObj({
'total_avail_bytes': int,
'total_bytes': int,
- 'total_objects': int,
'total_used_bytes': int,
'total_used_raw_bytes': int,
'total_used_raw_ratio': float
'up': int,
}, allow_unknown=True)),
}, allow_unknown=True),
- 'pg_info': JObj({
- 'pgs_per_osd': float,
- 'statuses': JObj({}, allow_unknown=True, unknown_schema=int)
- }),
+ 'pg_info': self.__pg_info_schema,
'pools': JList(JLeaf(dict)),
'rgw': int,
'scrub_status': str
del df['stats_by_class']
- df['stats']['total_objects'] = sum(
- [p['stats']['objects'] for p in df['pools']])
if self._minimal:
df = dict(stats=self._partial_dict(
df['stats'],
- ['total_avail_bytes', 'total_bytes', 'total_objects',
+ ['total_avail_bytes', 'total_bytes',
'total_used_raw_bytes']
))
return df
return osd_map
def pg_info(self):
- pg_info = CephService.get_pg_info()
- if self._minimal:
- pg_info = self._partial_dict(pg_info, ['pgs_per_osd', 'statuses'])
- return pg_info
+ return CephService.get_pg_info()
def pools(self):
pools = CephService.get_pool_list_with_stats()
import { configureTestBed } from '../../../../testing/unit-test-helper';
import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '../../../shared/pipes/dimless.pipe';
import { FormatterService } from '../../../shared/services/formatter.service';
import { HealthPieComponent } from './health-pie.component';
configureTestBed({
schemas: [NO_ERRORS_SCHEMA],
declarations: [HealthPieComponent],
- providers: [DimlessBinaryPipe, FormatterService]
+ providers: [DimlessBinaryPipe, DimlessPipe, FormatterService]
});
beforeEach(() => {
expect(component).toBeTruthy();
});
- it('Set doughnut if nothing received', () => {
- component.chartType = '';
- fixture.detectChanges();
-
- expect(component.chartConfig.chartType).toEqual('doughnut');
- });
-
- it('Set doughnut if not allowed value received', () => {
- component.chartType = 'badType';
- fixture.detectChanges();
-
- expect(component.chartConfig.chartType).toEqual('doughnut');
- });
-
- it('Set doughnut if doughnut received', () => {
- component.chartType = 'doughnut';
- fixture.detectChanges();
-
- expect(component.chartConfig.chartType).toEqual('doughnut');
- });
-
- it('Set pie if pie received', () => {
- component.chartType = 'pie';
- fixture.detectChanges();
-
- expect(component.chartConfig.chartType).toEqual('pie');
- });
-
it('Add slice border if there is more than one slice with numeric non zero value', () => {
component.chartConfig.dataset[0].data = [48, 0, 1, 0];
component.ngOnChanges();
expect(component.chartConfig.dataset[0].data).toEqual(initialData);
});
+
+ describe('tooltip body', () => {
+ const tooltipBody = ['text: 10000'];
+
+ it('should return amount converted to appropriate units', () => {
+ component.isBytesData = false;
+ expect(component['getChartTooltipBody'](tooltipBody)).toEqual('text: 10 k');
+
+ component.isBytesData = true;
+ expect(component['getChartTooltipBody'](tooltipBody)).toEqual('text: 9.8 KiB');
+ });
+
+ it('should not return amount when showing label as tooltip', () => {
+ component.showLabelAsTooltip = true;
+ expect(component['getChartTooltipBody'](tooltipBody)).toEqual('text');
+ });
+ });
});
import { ChartTooltip } from '../../../shared/models/chart-tooltip';
import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '../../../shared/pipes/dimless.pipe';
import { HealthPieColor } from './health-pie-color.enum';
@Component({
@Input()
data: any;
@Input()
- chartType: string;
+ config = {};
@Input()
isBytesData = false;
@Input()
- displayLegend = false;
- @Input()
tooltipFn: any;
@Input()
showLabelAsTooltip = false;
prepareFn = new EventEmitter();
chartConfig: any = {
+ chartType: 'pie',
dataset: [
{
label: null,
],
options: {
legend: {
- display: false,
+ display: true,
position: 'right',
labels: { usePointStyle: true },
onClick: (event, legendItem) => {
}
},
animation: { duration: 0 },
-
tooltips: {
enabled: false
+ },
+ title: {
+ display: false
}
}
};
private hiddenSlices = [];
- constructor(private dimlessBinary: DimlessBinaryPipe) {}
+ constructor(private dimlessBinary: DimlessBinaryPipe, private dimless: DimlessPipe) {}
ngOnInit() {
// An extension to Chart.js to enable rendering some
chartTooltip.customTooltips(tooltip);
};
- this.setChartType();
-
- this.chartConfig.options.legend.display = this.displayLegend;
-
this.chartConfig.colors = [
{
backgroundColor: [
}
];
+ _.merge(this.chartConfig, this.config);
+
this.prepareFn.emit([this.chartConfig, this.data]);
}
return bodySplit[0];
}
- if (this.isBytesData) {
- bodySplit[1] = this.dimlessBinary.transform(bodySplit[1]);
- }
+ bodySplit[1] = this.isBytesData
+ ? this.dimlessBinary.transform(bodySplit[1])
+ : this.dimless.transform(bodySplit[1]);
return bodySplit.join(': ');
}
- private setChartType() {
- const chartTypes = ['doughnut', 'pie'];
- const selectedChartType = chartTypes.find((chartType) => chartType === this.chartType);
-
- if (selectedChartType !== undefined) {
- this.chartConfig.chartType = selectedChartType;
- } else {
- this.chartConfig.chartType = chartTypes[0];
- }
- }
-
private setChartSliceBorderWidth() {
let nonZeroValueSlices = 0;
_.forEach(this.chartConfig.dataset[0].data, function(slice) {
*ngIf="healthData.client_perf">
<cd-health-pie *ngIf="isClientReadWriteChartShowable()"
[data]="healthData"
- [isBytesData]="false"
- chartType="pie"
- [displayLegend]="true"
(prepareFn)="prepareReadWriteRatio($event[0], $event[1])">
</cd-health-pie>
<span *ngIf="!isClientReadWriteChartShowable()">
class="row info-group"
*ngIf="healthData.pools
|| healthData.df
- || healthData.df?.stats?.total_objects != null
|| healthData.pg_info">
<div class="cd-container-flex">
contentClass="content-chart"
*ngIf="healthData.df">
<cd-health-pie [data]="healthData"
+ [config]="rawCapacityChartConfig"
[showLabelAsTooltip]="true"
- chartType="pie"
- [displayLegend]="true"
(prepareFn)="prepareRawUsage($event[0], $event[1])">
</cd-health-pie>
</cd-info-card>
i18n-cardTitle
class="cd-col-5"
cardClass="card-medium"
- contentClass="content-medium content-highlight"
- *ngIf="healthData.df?.stats?.total_objects != null">
- {{ healthData.df?.stats?.total_objects }}
+ contentClass="content-chart"
+ *ngIf="healthData.pg_info?.object_stats?.num_objects != null">
+ <cd-health-pie [data]="healthData"
+ [config]="objectsChartConfig"
+ (prepareFn)="prepareObjects($event[0], $event[1])">
+ </cd-health-pie>
</cd-info-card>
<cd-info-card cardTitle="PGs per OSD"
#pgStatusTarget="bs-popover"
placement="bottom">
<cd-health-pie [data]="healthData"
- chartType="pie"
- [displayLegend]="true"
+ [config]="pgStatusChartConfig"
(prepareFn)="preparePgStatus($event[0], $event[1])">
</cd-health-pie>
</div>
import { RefreshIntervalService } from '../../../shared/services/refresh-interval.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';
client_perf: {},
scrub_status: 'Inactive',
pools: [],
- df: { stats: { total_objects: 0 } },
- pg_info: {}
+ df: { stats: {} },
+ pg_info: { object_stats: { num_objects: 0 } }
};
const fakeAuthStorageService = {
getPermissions: () => {
expect(preparePgStatus).toHaveBeenCalled();
});
+ it('event binding "prepareObjects" is called', () => {
+ const prepareObjects = spyOn(component, 'prepareObjects');
+
+ fixture.detectChanges();
+
+ expect(prepareObjects).toHaveBeenCalled();
+ });
+
describe('preparePgStatus', () => {
const calcPercentage = (data) => Math.round((data / 10) * 100) || 0;
const expectedChart = (data: number[]) => ({
- colors: [
- {
- backgroundColor: [
- HealthPieColor.DEFAULT_GREEN,
- HealthPieColor.DEFAULT_BLUE,
- HealthPieColor.DEFAULT_ORANGE,
- HealthPieColor.DEFAULT_RED
- ]
- }
- ],
labels: [
`Clean (${calcPercentage(data[0])}%)`,
`Working (${calcPercentage(data[1])}%)`,
expect(component.isClientReadWriteChartShowable()).toBeTruthy();
});
});
+
+ describe('calcPercentage', () => {
+ it('returns correct value', () => {
+ expect(component['calcPercentage'](1, undefined)).toEqual(0);
+ expect(component['calcPercentage'](1, null)).toEqual(0);
+ expect(component['calcPercentage'](1, 0)).toEqual(0);
+ expect(component['calcPercentage'](undefined, 1)).toEqual(0);
+ expect(component['calcPercentage'](null, 1)).toEqual(0);
+ expect(component['calcPercentage'](0, 1)).toEqual(0);
+ expect(component['calcPercentage'](2.346, 10)).toEqual(23);
+ expect(component['calcPercentage'](2.35, 10)).toEqual(24);
+ });
+ });
});
import { HealthService } from '../../../shared/api/health.service';
import { Permissions } from '../../../shared/models/permissions';
import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import { DimlessPipe } from '../../../shared/pipes/dimless.pipe';
import { AuthStorageService } from '../../../shared/services/auth-storage.service';
import {
FeatureTogglesMap$,
permissions: Permissions;
enabledFeature$: FeatureTogglesMap$;
+ rawCapacityChartConfig = {
+ options: {
+ title: { display: true, position: 'bottom' }
+ }
+ };
+ objectsChartConfig = {
+ options: {
+ title: { display: true, position: 'bottom' }
+ },
+ colors: [
+ {
+ backgroundColor: [
+ HealthPieColor.DEFAULT_GREEN,
+ HealthPieColor.DEFAULT_MAGENTA,
+ HealthPieColor.DEFAULT_ORANGE,
+ HealthPieColor.DEFAULT_RED
+ ]
+ }
+ ]
+ };
+ pgStatusChartConfig = {
+ colors: [
+ {
+ backgroundColor: [
+ HealthPieColor.DEFAULT_GREEN,
+ HealthPieColor.DEFAULT_BLUE,
+ HealthPieColor.DEFAULT_ORANGE,
+ HealthPieColor.DEFAULT_RED
+ ]
+ }
+ ]
+ };
+
constructor(
private healthService: HealthService,
private i18n: I18n,
private pgCategoryService: PgCategoryService,
private featureToggles: FeatureTogglesService,
private refreshIntervalService: RefreshIntervalService,
- private dimlessBinary: DimlessBinaryPipe
+ private dimlessBinary: DimlessBinaryPipe,
+ private dimless: DimlessPipe
) {
this.permissions = this.authStorageService.getPermissions();
this.enabledFeature$ = this.featureToggles.get();
const total =
this.healthData.client_perf.write_op_per_sec + this.healthData.client_perf.read_op_per_sec;
- const calcPercentage = (status) =>
- Math.round(((this.healthData.client_perf[status] || 0) / total) * 100);
- ratioLabels.push(`${this.i18n('Writes')} (${calcPercentage('write_op_per_sec')}%)`);
+ ratioLabels.push(
+ `${this.i18n('Writes')} (${this.calcPercentage(
+ this.healthData.client_perf.write_op_per_sec,
+ total
+ )}%)`
+ );
ratioData.push(this.healthData.client_perf.write_op_per_sec);
- ratioLabels.push(`${this.i18n('Reads')} (${calcPercentage('read_op_per_sec')}%)`);
+ ratioLabels.push(
+ `${this.i18n('Reads')} (${this.calcPercentage(
+ this.healthData.client_perf.read_op_per_sec,
+ total
+ )}%)`
+ );
ratioData.push(this.healthData.client_perf.read_op_per_sec);
chart.dataset[0].data = ratioData;
}
prepareRawUsage(chart, data) {
- const percentAvailable = Math.round(
- 100 *
- ((data.df.stats.total_bytes - data.df.stats.total_used_raw_bytes) /
- data.df.stats.total_bytes)
+ const percentAvailable = this.calcPercentage(
+ data.df.stats.total_bytes - data.df.stats.total_used_raw_bytes,
+ data.df.stats.total_bytes
);
-
- const percentUsed = Math.round(
- 100 * (data.df.stats.total_used_raw_bytes / data.df.stats.total_bytes)
+ const percentUsed = this.calcPercentage(
+ data.df.stats.total_used_raw_bytes,
+ data.df.stats.total_bytes
);
chart.dataset[0].data = [data.df.stats.total_used_raw_bytes, data.df.stats.total_avail_bytes];
- if (chart === 'doughnut') {
- chart.options.cutoutPercentage = 65;
- }
+
chart.labels = [
`${this.dimlessBinary.transform(data.df.stats.total_used_raw_bytes)} ${this.i18n(
'Used'
)} ${this.i18n('Avail.')} (${percentAvailable}%)`
];
- chart.options.title = {
- display: true,
- text: `${this.dimlessBinary.transform(data.df.stats.total_bytes)} total`,
- position: 'bottom'
- };
+ chart.options.title.text = `${this.dimlessBinary.transform(
+ data.df.stats.total_bytes
+ )} ${this.i18n('total')}`;
}
preparePgStatus(chart, data) {
const categoryPgAmount = {};
- chart.colors = [
- {
- backgroundColor: [
- HealthPieColor.DEFAULT_GREEN,
- HealthPieColor.DEFAULT_BLUE,
- HealthPieColor.DEFAULT_ORANGE,
- HealthPieColor.DEFAULT_RED
- ]
- }
- ];
_.forEach(data.pg_info.statuses, (pgAmount, pgStatesText) => {
const categoryType = this.pgCategoryService.getTypeByStates(pgStatesText);
.getAllTypes()
.map((categoryType) => categoryPgAmount[categoryType]);
- const calcPercentage = (status) =>
- Math.round(((categoryPgAmount[status] || 0) / data.pg_info.pgs_per_osd) * 100) || 0;
+ chart.labels = [
+ `${this.i18n('Clean')} (${this.calcPercentage(
+ categoryPgAmount['clean'],
+ data.pg_info.pgs_per_osd
+ )}%)`,
+ `${this.i18n('Working')} (${this.calcPercentage(
+ categoryPgAmount['working'],
+ data.pg_info.pgs_per_osd
+ )}%)`,
+ `${this.i18n('Warning')} (${this.calcPercentage(
+ categoryPgAmount['warning'],
+ data.pg_info.pgs_per_osd
+ )}%)`,
+ `${this.i18n('Unknown')} (${this.calcPercentage(
+ categoryPgAmount['unknown'],
+ data.pg_info.pgs_per_osd
+ )}%)`
+ ];
+ }
+
+ prepareObjects(chart, data) {
+ const totalReplicas = data.pg_info.object_stats.num_object_copies;
+ const healthy =
+ totalReplicas -
+ data.pg_info.object_stats.num_objects_misplaced -
+ data.pg_info.object_stats.num_objects_degraded -
+ data.pg_info.object_stats.num_objects_unfound;
chart.labels = [
- `${this.i18n('Clean')} (${calcPercentage('clean')}%)`,
- `${this.i18n('Working')} (${calcPercentage('working')}%)`,
- `${this.i18n('Warning')} (${calcPercentage('warning')}%)`,
- `${this.i18n('Unknown')} (${calcPercentage('unknown')}%)`
+ `${this.i18n('Healthy')} (${this.calcPercentage(healthy, totalReplicas)}%)`,
+ `${this.i18n('Misplaced')} (${this.calcPercentage(
+ data.pg_info.object_stats.num_objects_misplaced,
+ totalReplicas
+ )}%)`,
+ `${this.i18n('Degraded')} (${this.calcPercentage(
+ data.pg_info.object_stats.num_objects_degraded,
+ totalReplicas
+ )}%)`,
+ `${this.i18n('Unfound')} (${this.calcPercentage(
+ data.pg_info.object_stats.num_objects_unfound,
+ totalReplicas
+ )}%)`
];
+
+ chart.dataset[0].data = [
+ healthy,
+ data.pg_info.object_stats.num_objects_misplaced,
+ data.pg_info.object_stats.num_objects_degraded,
+ data.pg_info.object_stats.num_objects_unfound
+ ];
+
+ chart.options.title.text = `${this.dimless.transform(
+ data.pg_info.object_stats.num_objects
+ )} ${this.i18n('total')} (${this.dimless.transform(totalReplicas)} ${this.i18n('replicas')})`;
+
+ chart.options.maintainAspectRatio = window.innerWidth >= 375;
}
isClientReadWriteChartShowable() {
return readOps + writeOps > 0;
}
+
+ private calcPercentage(dividend: number, divisor: number) {
+ if (!_.isNumber(dividend) || !_.isNumber(divisor) || divisor === 0) {
+ return 0;
+ }
+
+ return Math.round((dividend / divisor) * 100);
+ }
}
from .. import logger, mgr
+try:
+ from typing import Dict, Any # pylint: disable=unused-import
+except ImportError:
+ pass # For typing only
+
class SendCommandError(rados.Error):
def __init__(self, err, prefix, argdict, errno):
@classmethod
def get_service_map(cls, service_name):
- service_map = {}
+ service_map = {} # type: Dict[str, Dict[str, Any]]
for server in mgr.list_servers():
for service in server['services']:
if service['type'] == service_name:
@classmethod
def get_pg_info(cls):
pg_summary = mgr.get('pg_summary')
+ object_stats = {stat: pg_summary['pg_stats_sum']['stat_sum'][stat] for stat in [
+ 'num_objects', 'num_object_copies', 'num_objects_degraded',
+ 'num_objects_misplaced', 'num_objects_unfound']}
pgs_per_osd = 0.0
total_osds = len(pg_summary['by_osd'])
pgs_per_osd = total_pgs / total_osds
return {
+ 'object_stats': object_stats,
'statuses': pg_summary['all'],
'pgs_per_osd': pgs_per_osd,
}