Commit 90b4946e authored by Andrei Stoicescu's avatar Andrei Stoicescu

Add gauge chart type to the monitoring dashboards

 - add component
 - docs
 - tests
parent 52083dab
<script>
import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlGaugeChart } from '@gitlab/ui/dist/charts';
import { graphDataValidatorForValues } from '../../utils';
import { getValidThresholds } from './options';
import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { isFinite, isArray, isInteger } from 'lodash';
export default {
components: {
GlGaugeChart,
},
directives: {
GlResizeObserverDirective,
},
props: {
graphData: {
type: Object,
required: true,
validator: graphDataValidatorForValues.bind(null, true),
},
},
data() {
return {
width: 0,
};
},
computed: {
rangeValues() {
let min = 0;
let max = 100;
const { minValue, maxValue } = this.graphData;
const isValidMinMax = () => {
return isFinite(minValue) && isFinite(maxValue) && minValue < maxValue;
};
if (isValidMinMax()) {
min = minValue;
max = maxValue;
}
return {
min,
max,
};
},
validThresholds() {
const { mode, values } = this.graphData?.thresholds || {};
const range = this.rangeValues;
if (!isArray(values)) {
return [];
}
return getValidThresholds({ mode, range, values });
},
queryResult() {
return this.graphData?.metrics[0]?.result[0]?.value[1];
},
splitValue() {
const { split } = this.graphData;
const defaultValue = 10;
return isInteger(split) && split > 0 ? split : defaultValue;
},
textValue() {
const formatFromPanel = this.graphData.format;
const defaultFormat = SUPPORTED_FORMATS.engineering;
const format = SUPPORTED_FORMATS[formatFromPanel] ?? defaultFormat;
const { queryResult } = this;
const formatter = getFormatter(format);
return isFinite(queryResult) ? formatter(queryResult) : '--';
},
thresholdsValue() {
/**
* If there are no valid thresholds, a default threshold
* will be set at 90% of the gauge arcs' max value
*/
const { min, max } = this.rangeValues;
const defaultThresholdValue = [(max - min) * 0.95];
return this.validThresholds.length ? this.validThresholds : defaultThresholdValue;
},
value() {
/**
* The gauge chart gitlab-ui component expects a value
* of type number.
*
* So, if the query result is undefined,
* we pass the gauge chart a value of NaN.
*/
return this.queryResult || NaN;
},
},
methods: {
onResize() {
if (!this.$refs.gaugeChart) return;
const { width } = this.$refs.gaugeChart.$el.getBoundingClientRect();
this.width = width;
},
},
};
</script>
<template>
<div v-gl-resize-observer-directive="onResize">
<gl-gauge-chart
ref="gaugeChart"
v-bind="$attrs"
:value="value"
:min="rangeValues.min"
:max="rangeValues.max"
:thresholds="thresholdsValue"
:text="textValue"
:split-number="splitValue"
:width="width"
/>
</div>
</template>
import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
import { __, s__ } from '~/locale';
import { isFinite, uniq, sortBy, includes } from 'lodash';
import { formatDate, timezones, formats } from '../../format_date';
import { thresholdModeTypes } from '../../constants';
const yAxisBoundaryGap = [0.1, 0.1];
/**
......@@ -109,3 +111,65 @@ export const getTooltipFormatter = ({
const formatter = getFormatter(format);
return num => formatter(num, precision);
};
// Thresholds
/**
*
* Used to find valid thresholds for the gauge chart
*
* An array of thresholds values is
* - duplicate values are removed;
* - filtered for invalid values;
* - sorted in ascending order;
* - only first two values are used.
*/
export const getValidThresholds = ({ mode, range = {}, values = [] } = {}) => {
const supportedModes = [thresholdModeTypes.ABSOLUTE, thresholdModeTypes.PERCENTAGE];
const { min, max } = range;
/**
* return early if min and max have invalid values
* or mode has invalid value
*/
if (!isFinite(min) || !isFinite(max) || min >= max || !includes(supportedModes, mode)) {
return [];
}
const uniqueThresholds = uniq(values);
const numberThresholds = uniqueThresholds.filter(threshold => isFinite(threshold));
const validThresholds = numberThresholds.filter(threshold => {
let isValid;
if (mode === thresholdModeTypes.PERCENTAGE) {
isValid = threshold > 0 && threshold < 100;
} else if (mode === thresholdModeTypes.ABSOLUTE) {
isValid = threshold > min && threshold < max;
}
return isValid;
});
const transformedThresholds = validThresholds.map(threshold => {
let transformedThreshold;
if (mode === 'percentage') {
transformedThreshold = (threshold / 100) * (max - min);
} else {
transformedThreshold = threshold;
}
return transformedThreshold;
});
const sortedThresholds = sortBy(transformedThresholds);
const reducedThresholdsArray =
sortedThresholds.length > 2
? [sortedThresholds[0], sortedThresholds[1]]
: [...sortedThresholds];
return reducedThresholdsArray;
};
......@@ -22,6 +22,7 @@ import MonitorEmptyChart from './charts/empty_chart.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
import MonitorGaugeChart from './charts/gauge.vue';
import MonitorHeatmapChart from './charts/heatmap.vue';
import MonitorColumnChart from './charts/column.vue';
import MonitorBarChart from './charts/bar.vue';
......@@ -170,6 +171,9 @@ export default {
if (this.isPanelType(panelTypes.SINGLE_STAT)) {
return MonitorSingleStatChart;
}
if (this.isPanelType(panelTypes.GAUGE_CHART)) {
return MonitorGaugeChart;
}
if (this.isPanelType(panelTypes.HEATMAP)) {
return MonitorHeatmapChart;
}
......@@ -215,7 +219,8 @@ export default {
return (
this.isPanelType(panelTypes.AREA_CHART) ||
this.isPanelType(panelTypes.LINE_CHART) ||
this.isPanelType(panelTypes.SINGLE_STAT)
this.isPanelType(panelTypes.SINGLE_STAT) ||
this.isPanelType(panelTypes.GAUGE_CHART)
);
},
editCustomMetricLink() {
......
......@@ -86,6 +86,10 @@ export const panelTypes = {
* Single data point visualization
*/
SINGLE_STAT: 'single-stat',
/**
* Gauge
*/
GAUGE_CHART: 'gauge-chart',
/**
* Heatmap
*/
......@@ -272,3 +276,8 @@ export const keyboardShortcutKeys = {
DOWNLOAD_CSV: 'd',
CHART_COPY: 'c',
};
export const thresholdModeTypes = {
ABSOLUTE: 'absolute',
PERCENTAGE: 'percentage',
};
......@@ -176,7 +176,11 @@ export const mapPanelToViewModel = ({
field,
metrics = [],
links = [],
min_value,
max_value,
split,
thresholds,
format,
}) => {
// Both `x_axis.name` and `x_label` are supported for now
// https://gitlab.com/gitlab-org/gitlab/issues/210521
......@@ -195,7 +199,11 @@ export const mapPanelToViewModel = ({
yAxis,
xAxis,
field,
minValue: min_value,
maxValue: max_value,
split,
thresholds,
format,
links: links.map(mapLinksToViewModel),
metrics: mapToMetricsViewModel(metrics),
};
......
---
title: Add gauge chart type to the monitoring dashboards
merge_request: 36674
author:
type: added
......@@ -227,6 +227,57 @@ panel_groups:
For example, if you have a query value of `53.6`, adding `%` as the unit results in a single stat value of `53.6%`, but if the maximum expected value of the query is `120`, the value would be `44.6%`. Adding the `max_value` causes the correct percentage value to display.
## Gauge
CAUTION: **Warning:**
This panel type is an _alpha_ feature, and is subject to change at any time
without prior notice!
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/207044) in GitLab 13.3.
To add a gauge panel type to a dashboard, look at the following sample dashboard file:
```yaml
dashboard: 'Dashboard Title'
panel_groups:
- group: 'Group Title'
panels:
- title: "Gauge"
type: "gauge-chart"
min_value: 0
max_value: 1000
split: 5
thresholds:
values: [60, 90]
mode: "percentage"
format: "kilobytes"
metrics:
- id: 10
query: 'floor(max(prometheus_http_response_size_bytes_bucket)/1000)'
unit: 'kb'
```
Note the following properties:
| Property | Type | Required | Description |
| ------ | ------ | ------ | ------ |
| type | string | yes | Type of panel to be rendered. For gauge panel types, set to `gauge-chart`. |
| min_value | number | no, defaults to `0` | The minimum value of the gauge chart axis. If either of `min_value` or `max_value` are not set, they both get their default values. |
| max_value | number | no, defaults to `100` | The maximum value of the gauge chart axis. If either of `min_value` or `max_value` are not set, they both get their default values. |
| split | number | no, defaults to `10` | The amount of split segments on the gauge chart axis. |
| thresholds | object | no | Thresholds configuration for the gauge chart axis. |
| format | string | no, defaults to `engineering` | Unit format used. See the [full list of units](yaml_number_format.md). |
| query | string | yes | For gauge panel types, you must use an [instant query](https://prometheus.io/docs/prometheus/latest/querying/api/#instant-queries). |
### Thresholds properties
| Property | Type | Required | Description |
| ------ | ------ | ------ | ------ |
| values | array | no, defaults to 95% of the range between `min_value` and `max_value`| An array of gauge chart axis threshold values. |
| mode | string | no, defaults to `absolute` | The mode in which the thresholds are interpreted in relation to `min_value` and `max_value`. Can be either `percentage` or `absolute`. |
![gauge chart panel type](img/prometheus_dashboard_gauge_panel_type_v13_3.png)
## Heatmaps
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/30581) in GitLab 12.5.
......
import { shallowMount } from '@vue/test-utils';
import { GlGaugeChart } from '@gitlab/ui/dist/charts';
import GaugeChart from '~/monitoring/components/charts/gauge.vue';
import { gaugeChartGraphData } from '../../graph_data';
describe('Gauge Chart component', () => {
const defaultGraphData = gaugeChartGraphData();
let wrapper;
const findGaugeChart = () => wrapper.find(GlGaugeChart);
const createWrapper = ({ ...graphProps } = {}) => {
wrapper = shallowMount(GaugeChart, {
propsData: {
graphData: {
...defaultGraphData,
...graphProps,
},
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('chart component', () => {
it('is rendered when props are passed', () => {
createWrapper();
expect(findGaugeChart().exists()).toBe(true);
});
});
describe('min and max', () => {
const MIN_DEFAULT = 0;
const MAX_DEFAULT = 100;
it('are passed to chart component', () => {
createWrapper();
expect(findGaugeChart().props('min')).toBe(100);
expect(findGaugeChart().props('max')).toBe(1000);
});
const invalidCases = [undefined, NaN, 'a string'];
it.each(invalidCases)(
'if min has invalid value, defaults are used for both min and max',
invalidValue => {
createWrapper({ minValue: invalidValue });
expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT);
expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT);
},
);
it.each(invalidCases)(
'if max has invalid value, defaults are used for both min and max',
invalidValue => {
createWrapper({ minValue: invalidValue });
expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT);
expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT);
},
);
it('if min is bigger than max, defaults are used for both min and max', () => {
createWrapper({ minValue: 100, maxValue: 0 });
expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT);
expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT);
});
});
describe('thresholds', () => {
it('thresholds are set on chart', () => {
createWrapper();
expect(findGaugeChart().props('thresholds')).toEqual([500, 800]);
});
it('when no thresholds are defined, a default threshold is defined at 95% of max_value', () => {
createWrapper({
minValue: 0,
maxValue: 100,
thresholds: {},
});
expect(findGaugeChart().props('thresholds')).toEqual([95]);
});
it('when out of min-max bounds thresholds are defined, a default threshold is defined at 95% of the range between min_value and max_value', () => {
createWrapper({
thresholds: {
values: [-10, 1500],
},
});
expect(findGaugeChart().props('thresholds')).toEqual([855]);
});
describe('when mode is absolute', () => {
it('only valid threshold values are used', () => {
createWrapper({
thresholds: {
mode: 'absolute',
values: [undefined, 10, 110, NaN, 'a string', 400],
},
});
expect(findGaugeChart().props('thresholds')).toEqual([110, 400]);
});
it('if all threshold values are invalid, a default threshold is defined at 95% of the range between min_value and max_value', () => {
createWrapper({
thresholds: {
mode: 'absolute',
values: [NaN, undefined, 'a string', 1500],
},
});
expect(findGaugeChart().props('thresholds')).toEqual([855]);
});
});
describe('when mode is percentage', () => {
it('when values outside of 0-100 bounds are used, a default threshold is defined at 95% of max_value', () => {
createWrapper({
thresholds: {
mode: 'percentage',
values: [110],
},
});
expect(findGaugeChart().props('thresholds')).toEqual([855]);
});
it('if all threshold values are invalid, a default threshold is defined at 95% of max_value', () => {
createWrapper({
thresholds: {
mode: 'percentage',
values: [NaN, undefined, 'a string', 1500],
},
});
expect(findGaugeChart().props('thresholds')).toEqual([855]);
});
});
});
describe('split (the number of ticks on the chart arc)', () => {
const SPLIT_DEFAULT = 10;
it('is passed to chart as prop', () => {
createWrapper();
expect(findGaugeChart().props('splitNumber')).toBe(20);
});
it('if not explicitly set, passes a default value to chart', () => {
createWrapper({ split: '' });
expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT);
});
it('if set as a number that is not an integer, passes the default value to chart', () => {
createWrapper({ split: 10.5 });
expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT);
});
it('if set as a negative number, passes the default value to chart', () => {
createWrapper({ split: -10 });
expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT);
});
});
describe('text (the text displayed on the gauge for the current value)', () => {
it('displays the query result value when format is not set', () => {
createWrapper({ format: '' });
expect(findGaugeChart().props('text')).toBe('3');
});
it('displays the query result value when format is set to invalid value', () => {
createWrapper({ format: 'invalid' });
expect(findGaugeChart().props('text')).toBe('3');
});
it('displays a formatted query result value when format is set', () => {
createWrapper();
expect(findGaugeChart().props('text')).toBe('3kB');
});
it('displays a placeholder value when metric is empty', () => {
createWrapper({ metrics: [] });
expect(findGaugeChart().props('text')).toBe('--');
});
});
describe('value', () => {
it('correct value is passed', () => {
createWrapper();
expect(findGaugeChart().props('value')).toBe(3);
});
});
});
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getYAxisOptions, getTooltipFormatter } from '~/monitoring/components/charts/options';
import {
getYAxisOptions,
getTooltipFormatter,
getValidThresholds,
} from '~/monitoring/components/charts/options';
describe('options spec', () => {
describe('getYAxisOptions', () => {
......@@ -82,4 +86,242 @@ describe('options spec', () => {
expect(formatter(1)).toBe('1.000B');
});
});
describe('getValidThresholds', () => {
const invalidCases = [null, undefined, NaN, 'a string', true, false];
let thresholds;
afterEach(() => {
thresholds = null;
});
it('returns same thresholds when passed values within range', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: 100 },
values: [10, 50],
});
expect(thresholds).toEqual([10, 50]);
});
it('filters out thresholds that are out of range', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: 100 },
values: [-5, 10, 110],
});
expect(thresholds).toEqual([10]);
});
it('filters out duplicate thresholds', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: 100 },
values: [5, 5, 10, 10],
});
expect(thresholds).toEqual([5, 10]);
});
it('sorts passed thresholds and applies only the first two in ascending order', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: 100 },
values: [10, 1, 35, 20, 5],
});
expect(thresholds).toEqual([1, 5]);
});
it('thresholds equal to min or max are filtered out', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: 100 },
values: [0, 100],
});
expect(thresholds).toEqual([]);
});
it.each(invalidCases)('invalid values for thresholds are filtered out', invalidValue => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: 100 },
values: [10, invalidValue],
});
expect(thresholds).toEqual([10]);
});
describe('range', () => {
it('when range is not defined, empty result is returned', () => {
thresholds = getValidThresholds({
mode: 'absolute',
values: [10, 20],
});
expect(thresholds).toEqual([]);
});
it('when min is not defined, empty result is returned', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { max: 100 },
values: [10, 20],
});
expect(thresholds).toEqual([]);
});
it('when max is not defined, empty result is returned', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0 },
values: [10, 20],
});
expect(thresholds).toEqual([]);
});
it('when min is larger than max, empty result is returned', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 100, max: 0 },
values: [10, 20],
});
expect(thresholds).toEqual([]);
});
it.each(invalidCases)(
'when min has invalid value, empty result is returned',
invalidValue => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: invalidValue, max: 100 },
values: [10, 20],
});
expect(thresholds).toEqual([]);
},
);
it.each(invalidCases)(
'when max has invalid value, empty result is returned',
invalidValue => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: invalidValue },
values: [10, 20],
});
expect(thresholds).toEqual([]);
},
);
});
describe('values', () => {
it('if values parameter is omitted, empty result is returned', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: 100 },
});
expect(thresholds).toEqual([]);
});
it('if there are no values passed, empty result is returned', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: 100 },
values: [],
});
expect(thresholds).toEqual([]);
});
it.each(invalidCases)(
'if invalid values are passed, empty result is returned',
invalidValue => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: 100 },
values: [invalidValue],
});
expect(thresholds).toEqual([]);
},
);
});
describe('mode', () => {
it.each(invalidCases)(
'if invalid values are passed, empty result is returned',
invalidValue => {
thresholds = getValidThresholds({
mode: invalidValue,
range: { min: 0, max: 100 },
values: [10, 50],
});
expect(thresholds).toEqual([]);
},
);
it('if mode is not passed, empty result is returned', () => {
thresholds = getValidThresholds({
range: { min: 0, max: 100 },
values: [10, 50],
});
expect(thresholds).toEqual([]);
});
describe('absolute mode', () => {
it('absolute mode behaves correctly', () => {
thresholds = getValidThresholds({
mode: 'absolute',
range: { min: 0, max: 100 },
values: [10, 50],
});
expect(thresholds).toEqual([10, 50]);
});
});
describe('percentage mode', () => {
it('percentage mode behaves correctly', () => {
thresholds = getValidThresholds({
mode: 'percentage',
range: { min: 0, max: 1000 },
values: [10, 50],
});
expect(thresholds).toEqual([100, 500]);
});
const outOfPercentBoundsValues = [-1, 0, 100, 101];
it.each(outOfPercentBoundsValues)(
'when values out of 0-100 range are passed, empty result is returned',
invalidValue => {
thresholds = getValidThresholds({
mode: 'percentage',
range: { min: 0, max: 1000 },
values: [invalidValue],
});
expect(thresholds).toEqual([]);
},
);
});
});
it('calling without passing object parameter returns empty array', () => {
thresholds = getValidThresholds();
expect(thresholds).toEqual([]);
});
});
});
......@@ -210,3 +210,39 @@ export const heatmapGraphData = (panelOptions = {}, dataOptions = {}) => {
...panelOptions,
});
};
/**
* Generate gauge chart mock graph data according to options
*
* @param {Object} panelOptions - Panel options as in YML.
*
*/
export const gaugeChartGraphData = (panelOptions = {}) => {
const {
minValue = 100,
maxValue = 1000,
split = 20,
thresholds = {
mode: 'absolute',
values: [500, 800],
},
format = 'kilobytes',
} = panelOptions;
return mapPanelToViewModel({
title: 'Gauge Chart Panel',
type: panelTypes.GAUGE_CHART,
min_value: minValue,
max_value: maxValue,
split,
thresholds,
format,
metrics: [
{
label: `Metric`,
state: metricStates.OK,
result: matrixSingleResult(),
},
],
});
};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment