Merge branch 'jivl-add-empty-state-graphs-null-values' into 'master'

Add empty state for graphs with no values

Closes #53018

See merge request gitlab-org/gitlab-ce!22630
This commit is contained in:
Clement Ho 2018-11-29 19:22:43 +00:00
commit a6664ad61d
7 changed files with 149 additions and 26 deletions

View file

@ -105,6 +105,9 @@ export default {
deploymentFlagData() { deploymentFlagData() {
return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag); return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag);
}, },
shouldRenderData() {
return this.graphData.queries.filter(s => s.result.length > 0).length > 0;
},
}, },
watch: { watch: {
hoverData() { hoverData() {
@ -120,17 +123,17 @@ export default {
}, },
draw() { draw() {
const breakpointSize = bp.getBreakpointSize(); const breakpointSize = bp.getBreakpointSize();
const query = this.graphData.queries[0];
const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width; const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width;
this.margin = measurements.large.margin; this.margin = measurements.large.margin;
if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') { if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') {
this.graphHeight = 300; this.graphHeight = 300;
this.margin = measurements.small.margin; this.margin = measurements.small.margin;
this.measurements = measurements.small; this.measurements = measurements.small;
} }
this.unitOfDisplay = query.unit || '';
this.yAxisLabel = this.graphData.y_label || 'Values'; this.yAxisLabel = this.graphData.y_label || 'Values';
this.legendTitle = query.label || 'Average';
this.graphWidth = svgWidth - this.margin.left - this.margin.right; this.graphWidth = svgWidth - this.margin.left - this.margin.right;
this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
this.baseGraphHeight = this.graphHeight - 50; this.baseGraphHeight = this.graphHeight - 50;
@ -139,8 +142,15 @@ export default {
// pixel offsets inside the svg and outside are not 1:1 // pixel offsets inside the svg and outside are not 1:1
this.realPixelRatio = svgWidth / this.baseGraphWidth; this.realPixelRatio = svgWidth / this.baseGraphWidth;
// set the legends on the axes
const [query] = this.graphData.queries;
this.legendTitle = query ? query.label : 'Average';
this.unitOfDisplay = query ? query.unit : '';
if (this.shouldRenderData) {
this.renderAxesPaths(); this.renderAxesPaths();
this.formatDeployments(); this.formatDeployments();
}
}, },
handleMouseOverGraph(e) { handleMouseOverGraph(e) {
let point = this.$refs.graphData.createSVGPoint(); let point = this.$refs.graphData.createSVGPoint();
@ -266,7 +276,7 @@ export default {
:y-axis-label="yAxisLabel" :y-axis-label="yAxisLabel"
:unit-of-display="unitOfDisplay" :unit-of-display="unitOfDisplay"
/> />
<svg ref="graphData" :viewBox="innerViewBox" class="graph-data"> <svg v-if="shouldRenderData" ref="graphData" :viewBox="innerViewBox" class="graph-data">
<slot name="additionalSvgContent" :graphDrawData="graphDrawData" /> <slot name="additionalSvgContent" :graphDrawData="graphDrawData" />
<graph-path <graph-path
v-for="(path, index) in timeSeries" v-for="(path, index) in timeSeries"
@ -293,8 +303,14 @@ export default {
@mousemove="handleMouseOverGraph($event);" @mousemove="handleMouseOverGraph($event);"
/> />
</svg> </svg>
<svg v-else :viewBox="innerViewBox" class="js-no-data-to-display">
<text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle">
{{ s__('Metrics|No data to display') }}
</text>
</svg>
</svg> </svg>
<graph-flag <graph-flag
v-if="shouldRenderData"
:real-pixel-ratio="realPixelRatio" :real-pixel-ratio="realPixelRatio"
:current-x-coordinate="currentXCoordinate" :current-x-coordinate="currentXCoordinate"
:current-data="currentData" :current-data="currentData"

View file

@ -7,10 +7,29 @@ function sortMetrics(metrics) {
.value(); .value();
} }
function checkQueryEmptyData(query) {
return {
...query,
result: query.result.filter(timeSeries => {
const newTimeSeries = timeSeries;
const hasValue = series =>
!Number.isNaN(series.value) && (series.value !== null || series.value !== undefined);
const hasNonNullValue = timeSeries.values.find(hasValue);
newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : [];
return newTimeSeries.values.length > 0;
}),
};
}
function removeTimeSeriesNoData(queries) {
return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []);
}
function normalizeMetrics(metrics) { function normalizeMetrics(metrics) {
return metrics.map(metric => ({ return metrics.map(metric => {
...metric, const queries = metric.queries.map(query => ({
queries: metric.queries.map(query => ({
...query, ...query,
result: query.result.map(result => ({ result: query.result.map(result => ({
...result, ...result,
@ -19,8 +38,13 @@ function normalizeMetrics(metrics) {
value: Number(value), value: Number(value),
})), })),
})), })),
})),
})); }));
return {
...metric,
queries: removeTimeSeriesNoData(queries),
};
});
} }
export default class MonitoringStore { export default class MonitoringStore {

View file

@ -0,0 +1,5 @@
---
title: Add empty state for graphs with no values
merge_request: 22630
author:
type: fixed

View file

@ -4067,6 +4067,9 @@ msgstr ""
msgid "Metrics|Learn about environments" msgid "Metrics|Learn about environments"
msgstr "" msgstr ""
msgid "Metrics|No data to display"
msgstr ""
msgid "Metrics|No deployed environments" msgid "Metrics|No deployed environments"
msgstr "" msgstr ""

View file

@ -5,6 +5,7 @@ import {
deploymentData, deploymentData,
convertDatesMultipleSeries, convertDatesMultipleSeries,
singleRowMetricsMultipleSeries, singleRowMetricsMultipleSeries,
queryWithoutData,
} from './mock_data'; } from './mock_data';
const tagsPath = 'http://test.host/frontend-fixtures/environments-project/tags'; const tagsPath = 'http://test.host/frontend-fixtures/environments-project/tags';
@ -104,4 +105,23 @@ describe('Graph', () => {
expect(component.currentData).toBe(component.timeSeries[0].values[10]); expect(component.currentData).toBe(component.timeSeries[0].values[10]);
}); });
describe('Without data to display', () => {
it('shows a "no data to display" empty state on a graph', done => {
const component = createComponent({
graphData: queryWithoutData,
deploymentData,
tagsPath,
projectPath,
});
Vue.nextTick(() => {
expect(
component.$el.querySelector('.js-no-data-to-display text').textContent.trim(),
).toEqual('No data to display');
done();
});
});
});
}); });

View file

@ -14,7 +14,7 @@ export const metricsGroupsAPIResponse = {
queries: [ queries: [
{ {
query_range: 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20', query_range: 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20',
y_label: 'Memory', label: 'Memory',
unit: 'MiB', unit: 'MiB',
result: [ result: [
{ {
@ -324,12 +324,15 @@ export const metricsGroupsAPIResponse = {
], ],
}, },
{ {
id: 6,
title: 'CPU usage', title: 'CPU usage',
weight: 1, weight: 1,
queries: [ queries: [
{ {
query_range: query_range:
'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100', 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100',
label: 'Core Usage',
unit: 'Cores',
result: [ result: [
{ {
metric: {}, metric: {},
@ -639,6 +642,39 @@ export const metricsGroupsAPIResponse = {
}, },
], ],
}, },
{
group: 'NGINX',
priority: 2,
metrics: [
{
id: 100,
title: 'Http Error Rate',
weight: 100,
queries: [
{
query_range:
'sum(rate(nginx_upstream_responses_total{status_code="5xx", upstream=~"nginx-test-8691397-production-.*"}[2m])) / sum(rate(nginx_upstream_responses_total{upstream=~"nginx-test-8691397-production-.*"}[2m])) * 100',
label: '5xx errors',
unit: '%',
result: [
{
metric: {},
values: [
[1495700554.925, NaN],
[1495700614.925, NaN],
[1495700674.925, NaN],
[1495700734.925, NaN],
[1495700794.925, NaN],
[1495700854.925, NaN],
[1495700914.925, NaN],
],
},
],
},
],
},
],
},
], ],
last_update: '2017-05-25T13:18:34.949Z', last_update: '2017-05-25T13:18:34.949Z',
}; };
@ -6526,6 +6562,21 @@ export const singleRowMetricsMultipleSeries = [
}, },
]; ];
export const queryWithoutData = {
title: 'HTTP Error rate',
weight: 10,
y_label: 'Http Error Rate',
queries: [
{
query_range:
'sum(rate(nginx_upstream_responses_total{status_code="5xx", upstream=~"nginx-test-8691397-production-.*"}[2m])) / sum(rate(nginx_upstream_responses_total{upstream=~"nginx-test-8691397-production-.*"}[2m])) * 100',
label: '5xx errors',
unit: '%',
result: [],
},
],
};
export function convertDatesMultipleSeries(multipleSeries) { export function convertDatesMultipleSeries(multipleSeries) {
const convertedMultiple = multipleSeries; const convertedMultiple = multipleSeries;
multipleSeries.forEach((column, index) => { multipleSeries.forEach((column, index) => {

View file

@ -1,31 +1,35 @@
import MonitoringStore from '~/monitoring/stores/monitoring_store'; import MonitoringStore from '~/monitoring/stores/monitoring_store';
import MonitoringMock, { deploymentData, environmentData } from './mock_data'; import MonitoringMock, { deploymentData, environmentData } from './mock_data';
describe('MonitoringStore', function() { describe('MonitoringStore', () => {
this.store = new MonitoringStore(); const store = new MonitoringStore();
this.store.storeMetrics(MonitoringMock.data); store.storeMetrics(MonitoringMock.data);
it('contains one group that contains two queries sorted by priority', () => { it('contains two groups that contains, one of which has two queries sorted by priority', () => {
expect(this.store.groups).toBeDefined(); expect(store.groups).toBeDefined();
expect(this.store.groups.length).toEqual(1); expect(store.groups.length).toEqual(2);
expect(this.store.groups[0].metrics.length).toEqual(2); expect(store.groups[0].metrics.length).toEqual(2);
}); });
it('gets the metrics count for every group', () => { it('gets the metrics count for every group', () => {
expect(this.store.getMetricsCount()).toEqual(2); expect(store.getMetricsCount()).toEqual(3);
}); });
it('contains deployment data', () => { it('contains deployment data', () => {
this.store.storeDeploymentData(deploymentData); store.storeDeploymentData(deploymentData);
expect(this.store.deploymentData).toBeDefined(); expect(store.deploymentData).toBeDefined();
expect(this.store.deploymentData.length).toEqual(3); expect(store.deploymentData.length).toEqual(3);
expect(typeof this.store.deploymentData[0]).toEqual('object'); expect(typeof store.deploymentData[0]).toEqual('object');
}); });
it('only stores environment data that contains deployments', () => { it('only stores environment data that contains deployments', () => {
this.store.storeEnvironmentsData(environmentData); store.storeEnvironmentsData(environmentData);
expect(this.store.environmentsData.length).toEqual(2); expect(store.environmentsData.length).toEqual(2);
});
it('removes the data if all the values from a query are not defined', () => {
expect(store.groups[1].metrics[0].queries[0].result.length).toEqual(0);
}); });
}); });