Commit 3ea0395e authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '5366-display-anomaly-deviation-boundaries-on-dashboard-ee' into 'master'

Add anomaly chart component

See merge request gitlab-org/gitlab!16530
parents fd7caccb e8b9f633
<script>
import { flatten, isNumber } from 'underscore';
import { GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { roundOffFloat } from '~/lib/utils/common_utils';
import { hexToRgb } from '~/lib/utils/color_utils';
import { areaOpacityValues, symbolSizes, colorValues } from '../../constants';
import { graphDataValidatorForAnomalyValues } from '../../utils';
import MonitorTimeSeriesChart from './time_series.vue';
/**
* Series indexes
*/
const METRIC = 0;
const UPPER = 1;
const LOWER = 2;
/**
* Boundary area appearance
*/
const AREA_COLOR = colorValues.anomalyAreaColor;
const AREA_OPACITY = areaOpacityValues.default;
const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY})`;
/**
* The anomaly component highlights when a metric shows
* some anomalous behavior.
*
* It shows both a metric line and a boundary band in a
* time series chart, the boundary band shows the normal
* range of values the metric should take.
*
* This component accepts 3 queries, which contain the
* "metric", "upper" limit and "lower" limit.
*
* The upper and lower series are "stacked areas" visually
* to create the boundary band, and if any "metric" value
* is outside this band, it is highlighted to warn users.
*
* The boundary band stack must be painted above the 0 line
* so the area is shown correctly. If any of the values of
* the data are negative, the chart data is shifted to be
* above 0 line.
*
* The data passed to the time series is will always be
* positive, but reformatted to show the original values of
* data.
*
*/
export default {
components: {
GlLineChart,
GlChartSeriesLabel,
MonitorTimeSeriesChart,
},
inheritAttrs: false,
props: {
graphData: {
type: Object,
required: true,
validator: graphDataValidatorForAnomalyValues,
},
},
computed: {
series() {
return this.graphData.queries.map(query => {
const values = query.result[0] ? query.result[0].values : [];
return {
label: query.label,
data: values.filter(([, value]) => !Number.isNaN(value)),
};
});
},
/**
* If any of the values of the data is negative, the
* chart data is shifted to the lowest value
*
* This offset is the lowest value.
*/
yOffset() {
const values = flatten(this.series.map(ser => ser.data.map(([, y]) => y)));
const min = values.length ? Math.floor(Math.min(...values)) : 0;
return min < 0 ? -min : 0;
},
metricData() {
const originalMetricQuery = this.graphData.queries[0];
const metricQuery = { ...originalMetricQuery };
metricQuery.result[0].values = metricQuery.result[0].values.map(([x, y]) => [
x,
y + this.yOffset,
]);
return {
...this.graphData,
type: 'line-chart',
queries: [metricQuery],
};
},
metricSeriesConfig() {
return {
type: 'line',
symbol: 'circle',
symbolSize: (val, params) => {
if (this.isDatapointAnomaly(params.dataIndex)) {
return symbolSizes.anomaly;
}
// 0 causes echarts to throw an error, use small number instead
// see https://gitlab.com/gitlab-org/gitlab-ui/issues/423
return 0.001;
},
showSymbol: true,
itemStyle: {
color: params => {
if (this.isDatapointAnomaly(params.dataIndex)) {
return colorValues.anomalySymbol;
}
return colorValues.primaryColor;
},
},
};
},
chartOptions() {
const [, upperSeries, lowerSeries] = this.series;
const calcOffsetY = (data, offsetCallback) =>
data.map((value, dataIndex) => {
const [x, y] = value;
return [x, y + offsetCallback(dataIndex)];
});
const yAxisWithOffset = {
name: this.yAxisLabel,
axisLabel: {
formatter: num => roundOffFloat(num - this.yOffset, 3).toString(),
},
};
/**
* Boundary is rendered by 2 series: An invisible
* series (opacity: 0) stacked on a visible one.
*
* Order is important, lower boundary is stacked
* *below* the upper boundary.
*/
const boundarySeries = [];
if (upperSeries.data.length && lowerSeries.data.length) {
// Lower boundary, plus the offset if negative values
boundarySeries.push(
this.makeBoundarySeries({
name: this.formatLegendLabel(lowerSeries),
data: calcOffsetY(lowerSeries.data, () => this.yOffset),
}),
);
// Upper boundary, minus the lower boundary
boundarySeries.push(
this.makeBoundarySeries({
name: this.formatLegendLabel(upperSeries),
data: calcOffsetY(upperSeries.data, i => -this.yValue(LOWER, i)),
areaStyle: {
color: AREA_COLOR,
opacity: AREA_OPACITY,
},
}),
);
}
return { yAxis: yAxisWithOffset, series: boundarySeries };
},
},
methods: {
formatLegendLabel(query) {
return query.label;
},
yValue(seriesIndex, dataIndex) {
const d = this.series[seriesIndex].data[dataIndex];
return d && d[1];
},
yValueFormatted(seriesIndex, dataIndex) {
const y = this.yValue(seriesIndex, dataIndex);
return isNumber(y) ? y.toFixed(3) : '';
},
isDatapointAnomaly(dataIndex) {
const yVal = this.yValue(METRIC, dataIndex);
const yUpper = this.yValue(UPPER, dataIndex);
const yLower = this.yValue(LOWER, dataIndex);
return (isNumber(yUpper) && yVal > yUpper) || (isNumber(yLower) && yVal < yLower);
},
makeBoundarySeries(series) {
const stackKey = 'anomaly-boundary-series-stack';
return {
type: 'line',
stack: stackKey,
lineStyle: {
width: 0,
color: AREA_COLOR_RGBA, // legend color
},
color: AREA_COLOR_RGBA, // tooltip color
symbol: 'none',
...series,
};
},
},
};
</script>
<template>
<monitor-time-series-chart
v-bind="$attrs"
:graph-data="metricData"
:option="chartOptions"
:series-config="metricSeriesConfig"
>
<slot></slot>
<template v-slot:tooltipContent="slotProps">
<div
v-for="(content, seriesIndex) in slotProps.tooltip.content"
:key="seriesIndex"
class="d-flex justify-content-between"
>
<gl-chart-series-label :color="content.color">
{{ content.name }}
</gl-chart-series-label>
<div class="prepend-left-32">
{{ yValueFormatted(seriesIndex, content.dataIndex) }}
</div>
</div>
</template>
</monitor-time-series-chart>
</template>
<script> <script>
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import _ from 'underscore';
import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { roundOffFloat } from '~/lib/utils/common_utils'; import { roundOffFloat } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { chartHeight, graphTypes, lineTypes, symbolSizes, dateFormats } from '../../constants'; import {
chartHeight,
graphTypes,
lineTypes,
lineWidths,
symbolSizes,
dateFormats,
} from '../../constants';
import { makeDataSeries } from '~/helpers/monitor_helper'; import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils'; import { graphDataValidatorForValues } from '../../utils';
...@@ -30,6 +38,16 @@ export default { ...@@ -30,6 +38,16 @@ export default {
required: true, required: true,
validator: graphDataValidatorForValues.bind(null, false), validator: graphDataValidatorForValues.bind(null, false),
}, },
option: {
type: Object,
required: false,
default: () => ({}),
},
seriesConfig: {
type: Object,
required: false,
default: () => ({}),
},
deploymentData: { deploymentData: {
type: Array, type: Array,
required: false, required: false,
...@@ -96,29 +114,35 @@ export default { ...@@ -96,29 +114,35 @@ export default {
const lineWidth = const lineWidth =
appearance && appearance.line && appearance.line.width appearance && appearance.line && appearance.line.width
? appearance.line.width ? appearance.line.width
: undefined; : lineWidths.default;
const areaStyle = { const areaStyle = {
opacity: opacity:
appearance && appearance.area && typeof appearance.area.opacity === 'number' appearance && appearance.area && typeof appearance.area.opacity === 'number'
? appearance.area.opacity ? appearance.area.opacity
: undefined, : undefined,
}; };
const series = makeDataSeries(query.result, { const series = makeDataSeries(query.result, {
name: this.formatLegendLabel(query), name: this.formatLegendLabel(query),
lineStyle: { lineStyle: {
type: lineType, type: lineType,
width: lineWidth, width: lineWidth,
color: this.primaryColor,
}, },
showSymbol: false, showSymbol: false,
areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined, areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined,
...this.seriesConfig,
}); });
return acc.concat(series); return acc.concat(series);
}, []); }, []);
}, },
chartOptionSeries() {
return (this.option.series || []).concat(this.scatterSeries ? [this.scatterSeries] : []);
},
chartOptions() { chartOptions() {
const option = _.omit(this.option, 'series');
return { return {
series: this.chartOptionSeries,
xAxis: { xAxis: {
name: __('Time'), name: __('Time'),
type: 'time', type: 'time',
...@@ -135,8 +159,8 @@ export default { ...@@ -135,8 +159,8 @@ export default {
formatter: num => roundOffFloat(num, 3).toString(), formatter: num => roundOffFloat(num, 3).toString(),
}, },
}, },
series: this.scatterSeries,
dataZoom: [this.dataZoomConfig], dataZoom: [this.dataZoomConfig],
...option,
}; };
}, },
dataZoomConfig() { dataZoomConfig() {
...@@ -144,6 +168,14 @@ export default { ...@@ -144,6 +168,14 @@ export default {
return handleIcon ? { handleIcon } : {}; return handleIcon ? { handleIcon } : {};
}, },
/**
* This method returns the earliest time value in all series of a chart.
* Takes a chart data with data to populate a timeseries.
* data should be an array of data points [t, y] where t is a ISO formatted date,
* and is sorted by t (time).
* @returns {(String|null)} earliest x value from all series, or null when the
* chart series data is empty.
*/
earliestDatapoint() { earliestDatapoint() {
return this.chartData.reduce((acc, series) => { return this.chartData.reduce((acc, series) => {
const { data } = series; const { data } = series;
...@@ -230,10 +262,11 @@ export default { ...@@ -230,10 +262,11 @@ export default {
this.tooltip.sha = deploy.sha.substring(0, 8); this.tooltip.sha = deploy.sha.substring(0, 8);
this.tooltip.commitUrl = deploy.commitUrl; this.tooltip.commitUrl = deploy.commitUrl;
} else { } else {
const { seriesName, color } = dataPoint; const { seriesName, color, dataIndex } = dataPoint;
const value = yVal.toFixed(3); const value = yVal.toFixed(3);
this.tooltip.content.push({ this.tooltip.content.push({
name: seriesName, name: seriesName,
dataIndex,
value, value,
color, color,
}); });
...@@ -306,23 +339,27 @@ export default { ...@@ -306,23 +339,27 @@ export default {
</template> </template>
<template v-else> <template v-else>
<template slot="tooltipTitle"> <template slot="tooltipTitle">
<div class="text-nowrap"> <slot name="tooltipTitle">
{{ tooltip.title }} <div class="text-nowrap">
</div> {{ tooltip.title }}
</div>
</slot>
</template> </template>
<template slot="tooltipContent"> <template slot="tooltipContent">
<div <slot name="tooltipContent" :tooltip="tooltip">
v-for="(content, key) in tooltip.content" <div
:key="key" v-for="(content, key) in tooltip.content"
class="d-flex justify-content-between" :key="key"
> class="d-flex justify-content-between"
<gl-chart-series-label :color="isMultiSeries ? content.color : ''"> >
{{ content.name }} <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
</gl-chart-series-label> {{ content.name }}
<div class="prepend-left-32"> </gl-chart-series-label>
{{ content.value }} <div class="prepend-left-32">
{{ content.value }}
</div>
</div> </div>
</div> </slot>
</template> </template>
</template> </template>
</component> </component>
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorSingleStatChart from './charts/single_stat.vue'; import MonitorSingleStatChart from './charts/single_stat.vue';
import MonitorEmptyChart from './charts/empty_chart.vue'; import MonitorEmptyChart from './charts/empty_chart.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event'; import TrackEventDirective from '~/vue_shared/directives/track_event';
...@@ -19,7 +20,6 @@ import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; ...@@ -19,7 +20,6 @@ import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
export default { export default {
components: { components: {
MonitorSingleStatChart, MonitorSingleStatChart,
MonitorTimeSeriesChart,
MonitorEmptyChart, MonitorEmptyChart,
Icon, Icon,
GlDropdown, GlDropdown,
...@@ -67,6 +67,12 @@ export default { ...@@ -67,6 +67,12 @@ export default {
const data = new Blob([this.csvText], { type: 'text/plain' }); const data = new Blob([this.csvText], { type: 'text/plain' });
return window.URL.createObjectURL(data); return window.URL.createObjectURL(data);
}, },
monitorChartComponent() {
if (this.isPanelType('anomaly-chart')) {
return MonitorAnomalyChart;
}
return MonitorTimeSeriesChart;
},
}, },
methods: { methods: {
getGraphAlerts(queries) { getGraphAlerts(queries) {
...@@ -93,13 +99,14 @@ export default { ...@@ -93,13 +99,14 @@ export default {
v-if="isPanelType('single-stat') && graphDataHasMetrics" v-if="isPanelType('single-stat') && graphDataHasMetrics"
:graph-data="graphData" :graph-data="graphData"
/> />
<monitor-time-series-chart <component
:is="monitorChartComponent"
v-else-if="graphDataHasMetrics" v-else-if="graphDataHasMetrics"
:graph-data="graphData" :graph-data="graphData"
:deployment-data="deploymentData" :deployment-data="deploymentData"
:project-path="projectPath" :project-path="projectPath"
:thresholds="getGraphAlertValues(graphData.queries)" :thresholds="getGraphAlertValues(graphData.queries)"
group-id="monitor-area-chart" group-id="panel-type-chart"
> >
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<alert-widget <alert-widget
...@@ -141,6 +148,6 @@ export default { ...@@ -141,6 +148,6 @@ export default {
</gl-dropdown-item> </gl-dropdown-item>
</gl-dropdown> </gl-dropdown>
</div> </div>
</monitor-time-series-chart> </component>
<monitor-empty-chart v-else :graph-title="graphData.title" /> <monitor-empty-chart v-else :graph-title="graphData.title" />
</template> </template>
...@@ -14,13 +14,28 @@ export const graphTypes = { ...@@ -14,13 +14,28 @@ export const graphTypes = {
}; };
export const symbolSizes = { export const symbolSizes = {
anomaly: 8,
default: 14, default: 14,
}; };
export const areaOpacityValues = {
default: 0.2,
};
export const colorValues = {
primaryColor: '#1f78d1', // $blue-500 (see variables.scss)
anomalySymbol: '#db3b21',
anomalyAreaColor: '#1f78d1',
};
export const lineTypes = { export const lineTypes = {
default: 'solid', default: 'solid',
}; };
export const lineWidths = {
default: 2,
};
export const timeWindows = { export const timeWindows = {
thirtyMinutes: __('30 minutes'), thirtyMinutes: __('30 minutes'),
threeHours: __('3 hours'), threeHours: __('3 hours'),
......
...@@ -131,4 +131,20 @@ export const downloadCSVOptions = title => { ...@@ -131,4 +131,20 @@ export const downloadCSVOptions = title => {
return { category, action, label: 'Chart title', property: title }; return { category, action, label: 'Chart title', property: title };
}; };
/**
* This function validates the graph data contains exactly 3 queries plus
* value validations from graphDataValidatorForValues.
* @param {Object} isValues
* @param {Object} graphData the graph data response from a prometheus request
* @returns {boolean} true if the data is valid
*/
export const graphDataValidatorForAnomalyValues = graphData => {
const anomalySeriesCount = 3; // metric, upper, lower
return (
graphData.queries &&
graphData.queries.length === anomalySeriesCount &&
graphDataValidatorForValues(false, graphData)
);
};
export default {}; export default {};
---
title: Added new chart component to display an anomaly boundary
merge_request: 16530
author:
type: added
import Anomaly from '~/monitoring/components/charts/anomaly.vue';
import { shallowMount } from '@vue/test-utils';
import { colorValues } from '~/monitoring/constants';
import {
anomalyDeploymentData,
mockProjectDir,
anomalyMockGraphData,
anomalyMockResultValues,
} from '../../mock_data';
import { TEST_HOST } from 'helpers/test_constants';
import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue';
const mockWidgets = 'mockWidgets';
const mockProjectPath = `${TEST_HOST}${mockProjectDir}`;
jest.mock('~/lib/utils/icon_utils'); // mock getSvgIconPathContent
const makeAnomalyGraphData = (datasetName, template = anomalyMockGraphData) => {
const queries = anomalyMockResultValues[datasetName].map((values, index) => ({
...template.queries[index],
result: [
{
metrics: {},
values,
},
],
}));
return { ...template, queries };
};
describe('Anomaly chart component', () => {
let wrapper;
const setupAnomalyChart = props => {
wrapper = shallowMount(Anomaly, {
propsData: { ...props },
slots: {
default: mockWidgets,
},
sync: false,
});
};
const findTimeSeries = () => wrapper.find(MonitorTimeSeriesChart);
const getTimeSeriesProps = () => findTimeSeries().props();
describe('wrapped monitor-time-series-chart component', () => {
const dataSetName = 'noAnomaly';
const dataSet = anomalyMockResultValues[dataSetName];
const inputThresholds = ['some threshold'];
beforeEach(() => {
setupAnomalyChart({
graphData: makeAnomalyGraphData(dataSetName),
deploymentData: anomalyDeploymentData,
thresholds: inputThresholds,
projectPath: mockProjectPath,
});
});
it('is a Vue instance', () => {
expect(findTimeSeries().exists()).toBe(true);
expect(findTimeSeries().isVueInstance()).toBe(true);
});
describe('receives props correctly', () => {
describe('graph-data', () => {
it('receives a single "metric" series', () => {
const { graphData } = getTimeSeriesProps();
expect(graphData.queries.length).toBe(1);
});
it('receives "metric" with all data', () => {
const { graphData } = getTimeSeriesProps();
const query = graphData.queries[0];
const expectedQuery = makeAnomalyGraphData(dataSetName).queries[0];
expect(query).toEqual(expectedQuery);
});
it('receives the "metric" results', () => {
const { graphData } = getTimeSeriesProps();
const { result } = graphData.queries[0];
const { values } = result[0];
const [metricDataset] = dataSet;
expect(values).toEqual(expect.any(Array));
values.forEach(([, y], index) => {
expect(y).toBeCloseTo(metricDataset[index][1]);
});
});
});
describe('option', () => {
let option;
let series;
beforeEach(() => {
({ option } = getTimeSeriesProps());
({ series } = option);
});
it('contains a boundary band', () => {
expect(series).toEqual(expect.any(Array));
expect(series.length).toEqual(2); // 1 upper + 1 lower boundaries
expect(series[0].stack).toEqual(series[1].stack);
series.forEach(s => {
expect(s.type).toBe('line');
expect(s.lineStyle.width).toBe(0);
expect(s.lineStyle.color).toMatch(/rgba\(.+\)/);
expect(s.lineStyle.color).toMatch(s.color);
expect(s.symbol).toEqual('none');
});
});
it('upper boundary values are stacked on top of lower boundary', () => {
const [lowerSeries, upperSeries] = series;
const [, upperDataset, lowerDataset] = dataSet;
lowerSeries.data.forEach(([, y], i) => {
expect(y).toBeCloseTo(lowerDataset[i][1]);
});
upperSeries.data.forEach(([, y], i) => {
expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]);
});
});
});
describe('series-config', () => {
let seriesConfig;
beforeEach(() => {
({ seriesConfig } = getTimeSeriesProps());
});
it('display symbols is enabled', () => {
expect(seriesConfig).toEqual(
expect.objectContaining({
type: 'line',
symbol: 'circle',
showSymbol: true,
symbolSize: expect.any(Function),
itemStyle: {
color: expect.any(Function),
},
}),
);
});
it('does not display anomalies', () => {
const { symbolSize, itemStyle } = seriesConfig;
const [metricDataset] = dataSet;
metricDataset.forEach((v, dataIndex) => {
const size = symbolSize(null, { dataIndex });
const color = itemStyle.color({ dataIndex });
// normal color and small size
expect(size).toBeCloseTo(0);
expect(color).toBe(colorValues.primaryColor);
});
});
it('can format y values (to use in tooltips)', () => {
expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]);
expect(parseFloat(wrapper.vm.yValueFormatted(1, 0))).toEqual(dataSet[1][0][1]);
expect(parseFloat(wrapper.vm.yValueFormatted(2, 0))).toEqual(dataSet[2][0][1]);
});
});
describe('inherited properties', () => {
it('"deployment-data" keeps the same value', () => {
const { deploymentData } = getTimeSeriesProps();
expect(deploymentData).toEqual(anomalyDeploymentData);
});
it('"thresholds" keeps the same value', () => {
const { thresholds } = getTimeSeriesProps();
expect(thresholds).toEqual(inputThresholds);
});
it('"projectPath" keeps the same value', () => {
const { projectPath } = getTimeSeriesProps();
expect(projectPath).toEqual(mockProjectPath);
});
});
});
});
describe('with no boundary data', () => {
const dataSetName = 'noBoundary';
const dataSet = anomalyMockResultValues[dataSetName];
beforeEach(() => {
setupAnomalyChart({
graphData: makeAnomalyGraphData(dataSetName),
deploymentData: anomalyDeploymentData,
});
});
describe('option', () => {
let option;
let series;
beforeEach(() => {
({ option } = getTimeSeriesProps());
({ series } = option);
});
it('does not display a boundary band', () => {
expect(series).toEqual(expect.any(Array));
expect(series.length).toEqual(0); // no boundaries
});
it('can format y values (to use in tooltips)', () => {
expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]);
expect(wrapper.vm.yValueFormatted(1, 0)).toBe(''); // missing boundary
expect(wrapper.vm.yValueFormatted(2, 0)).toBe(''); // missing boundary
});
});
});
describe('with one anomaly', () => {
const dataSetName = 'oneAnomaly';
const dataSet = anomalyMockResultValues[dataSetName];
beforeEach(() => {
setupAnomalyChart({
graphData: makeAnomalyGraphData(dataSetName),
deploymentData: anomalyDeploymentData,
});
});
describe('series-config', () => {
it('displays one anomaly', () => {
const { seriesConfig } = getTimeSeriesProps();
const { symbolSize, itemStyle } = seriesConfig;
const [metricDataset] = dataSet;
const bigDots = metricDataset.filter((v, dataIndex) => {
const size = symbolSize(null, { dataIndex });
return size > 0.1;
});
const redDots = metricDataset.filter((v, dataIndex) => {
const color = itemStyle.color({ dataIndex });
return color === colorValues.anomalySymbol;
});
expect(bigDots.length).toBe(1);
expect(redDots.length).toBe(1);
});
});
});
describe('with offset', () => {
const dataSetName = 'negativeBoundary';
const dataSet = anomalyMockResultValues[dataSetName];
const expectedOffset = 4; // Lowst point in mock data is -3.70, it gets rounded
beforeEach(() => {
setupAnomalyChart({
graphData: makeAnomalyGraphData(dataSetName),
deploymentData: anomalyDeploymentData,
});
});
describe('receives props correctly', () => {
describe('graph-data', () => {
it('receives a single "metric" series', () => {
const { graphData } = getTimeSeriesProps();
expect(graphData.queries.length).toBe(1);
});
it('receives "metric" results and applies the offset to them', () => {
const { graphData } = getTimeSeriesProps();
const { result } = graphData.queries[0];
const { values } = result[0];
const [metricDataset] = dataSet;
expect(values).toEqual(expect.any(Array));
values.forEach(([, y], index) => {
expect(y).toBeCloseTo(metricDataset[index][1] + expectedOffset);
});
});
});
});
describe('option', () => {
it('upper boundary values are stacked on top of lower boundary, plus the offset', () => {
const { option } = getTimeSeriesProps();
const { series } = option;
const [lowerSeries, upperSeries] = series;
const [, upperDataset, lowerDataset] = dataSet;
lowerSeries.data.forEach(([, y], i) => {
expect(y).toBeCloseTo(lowerDataset[i][1] + expectedOffset);
});
upperSeries.data.forEach(([, y], i) => {
expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]);
});
});
});
});
});
export const mockProjectDir = '/frontend-fixtures/environments-project';
export const anomalyDeploymentData = [
{
id: 111,
iid: 3,
sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
ref: {
name: 'master',
},
created_at: '2019-08-19T22:00:00.000Z',
deployed_at: '2019-08-19T22:01:00.000Z',
tag: false,
'last?': true,
},
{
id: 110,
iid: 2,
sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
ref: {
name: 'master',
},
created_at: '2019-08-19T23:00:00.000Z',
deployed_at: '2019-08-19T23:00:00.000Z',
tag: false,
'last?': false,
},
];
export const anomalyMockResultValues = {
noAnomaly: [
[
['2019-08-19T19:00:00.000Z', 1.25],
['2019-08-19T20:00:00.000Z', 1.45],
['2019-08-19T21:00:00.000Z', 1.55],
['2019-08-19T22:00:00.000Z', 1.48],
],
[
// upper boundary
['2019-08-19T19:00:00.000Z', 2],
['2019-08-19T20:00:00.000Z', 2.55],
['2019-08-19T21:00:00.000Z', 2.65],
['2019-08-19T22:00:00.000Z', 3.0],
],
[
// lower boundary
['2019-08-19T19:00:00.000Z', 0.45],
['2019-08-19T20:00:00.000Z', 0.65],
['2019-08-19T21:00:00.000Z', 0.7],
['2019-08-19T22:00:00.000Z', 0.8],
],
],
noBoundary: [
[
['2019-08-19T19:00:00.000Z', 1.25],
['2019-08-19T20:00:00.000Z', 1.45],
['2019-08-19T21:00:00.000Z', 1.55],
['2019-08-19T22:00:00.000Z', 1.48],
],
[
// empty upper boundary
],
[
// empty lower boundary
],
],
oneAnomaly: [
[
['2019-08-19T19:00:00.000Z', 1.25],
['2019-08-19T20:00:00.000Z', 3.45], // anomaly
['2019-08-19T21:00:00.000Z', 1.55],
],
[
// upper boundary
['2019-08-19T19:00:00.000Z', 2],
['2019-08-19T20:00:00.000Z', 2.55],
['2019-08-19T21:00:00.000Z', 2.65],
],
[
// lower boundary
['2019-08-19T19:00:00.000Z', 0.45],
['2019-08-19T20:00:00.000Z', 0.65],
['2019-08-19T21:00:00.000Z', 0.7],
],
],
negativeBoundary: [
[
['2019-08-19T19:00:00.000Z', 1.25],
['2019-08-19T20:00:00.000Z', 3.45], // anomaly
['2019-08-19T21:00:00.000Z', 1.55],
],
[
// upper boundary
['2019-08-19T19:00:00.000Z', 2],
['2019-08-19T20:00:00.000Z', 2.55],
['2019-08-19T21:00:00.000Z', 2.65],
],
[
// lower boundary
['2019-08-19T19:00:00.000Z', -1.25],
['2019-08-19T20:00:00.000Z', -2.65],
['2019-08-19T21:00:00.000Z', -3.7], // lowest point
],
],
};
export const anomalyMockGraphData = {
title: 'Requests Per Second Mock Data',
type: 'anomaly-chart',
weight: 3,
metrics: [
// Not used
],
queries: [
{
metricId: '90',
id: 'metric',
query_range: 'MOCK_PROMETHEUS_METRIC_QUERY_RANGE',
unit: 'RPS',
label: 'Metrics RPS',
metric_id: 90,
prometheus_endpoint_path: 'MOCK_METRIC_PEP',
result: [
{
metric: {},
values: [['2019-08-19T19:00:00.000Z', 0]],
},
],
},
{
metricId: '91',
id: 'upper',
query_range: '...',
unit: 'RPS',
label: 'Upper Limit Metrics RPS',
metric_id: 91,
prometheus_endpoint_path: 'MOCK_UPPER_PEP',
result: [
{
metric: {},
values: [['2019-08-19T19:00:00.000Z', 0]],
},
],
},
{
metricId: '92',
id: 'lower',
query_range: '...',
unit: 'RPS',
label: 'Lower Limit Metrics RPS',
metric_id: 92,
prometheus_endpoint_path: 'MOCK_LOWER_PEP',
result: [
{
metric: {},
values: [['2019-08-19T19:00:00.000Z', 0]],
},
],
},
],
};
...@@ -29,7 +29,6 @@ describe('Time series component', () => { ...@@ -29,7 +29,6 @@ describe('Time series component', () => {
shallowMount(TimeSeries, { shallowMount(TimeSeries, {
propsData: { propsData: {
graphData: { ...graphData, type }, graphData: { ...graphData, type },
containerWidth: 0,
deploymentData: store.state.monitoringDashboard.deploymentData, deploymentData: store.state.monitoringDashboard.deploymentData,
projectPath, projectPath,
}, },
...@@ -82,7 +81,7 @@ describe('Time series component', () => { ...@@ -82,7 +81,7 @@ describe('Time series component', () => {
seriesName: timeSeriesChart.vm.chartData[0].name, seriesName: timeSeriesChart.vm.chartData[0].name,
componentSubType: type, componentSubType: type,
value: [mockDate, 5.55555], value: [mockDate, 5.55555],
seriesIndex: 0, dataIndex: 0,
}, },
], ],
value: mockDate, value: mockDate,
...@@ -101,11 +100,15 @@ describe('Time series component', () => { ...@@ -101,11 +100,15 @@ describe('Time series component', () => {
it('formats tooltip content', () => { it('formats tooltip content', () => {
const name = 'Core Usage'; const name = 'Core Usage';
const value = '5.556'; const value = '5.556';
const dataIndex = 0;
const seriesLabel = timeSeriesChart.find(GlChartSeriesLabel); const seriesLabel = timeSeriesChart.find(GlChartSeriesLabel);
expect(seriesLabel.vm.color).toBe(''); expect(seriesLabel.vm.color).toBe('');
expect(shallowWrapperContainsSlotText(seriesLabel, 'default', name)).toBe(true); expect(shallowWrapperContainsSlotText(seriesLabel, 'default', name)).toBe(true);
expect(timeSeriesChart.vm.tooltip.content).toEqual([{ name, value, color: undefined }]); expect(timeSeriesChart.vm.tooltip.content).toEqual([
{ name, value, dataIndex, color: undefined },
]);
expect( expect(
shallowWrapperContainsSlotText( shallowWrapperContainsSlotText(
timeSeriesChart.find(GlAreaChart), timeSeriesChart.find(GlAreaChart),
...@@ -212,6 +215,39 @@ describe('Time series component', () => { ...@@ -212,6 +215,39 @@ describe('Time series component', () => {
}); });
describe('chartOptions', () => { describe('chartOptions', () => {
describe('are extended by `option`', () => {
const mockSeriesName = 'Extra series 1';
const mockOption = {
option1: 'option1',
option2: 'option2',
};
it('arbitrary options', () => {
timeSeriesChart.setProps({
option: mockOption,
});
expect(timeSeriesChart.vm.chartOptions).toEqual(jasmine.objectContaining(mockOption));
});
it('additional series', () => {
timeSeriesChart.setProps({
option: {
series: [
{
name: mockSeriesName,
},
],
},
});
const optionSeries = timeSeriesChart.vm.chartOptions.series;
expect(optionSeries.length).toEqual(2);
expect(optionSeries[0].name).toEqual(mockSeriesName);
});
});
describe('yAxis formatter', () => { describe('yAxis formatter', () => {
let format; let format;
......
import { anomalyMockGraphData as importedAnomalyMockGraphData } from '../../frontend/monitoring/mock_data';
export const anomalyMockGraphData = importedAnomalyMockGraphData;
export const mockApiEndpoint = `${gl.TEST_HOST}/monitoring/mock`; export const mockApiEndpoint = `${gl.TEST_HOST}/monitoring/mock`;
export const mockProjectPath = '/frontend-fixtures/environments-project'; export const mockProjectPath = '/frontend-fixtures/environments-project';
...@@ -975,7 +979,7 @@ export const graphDataPrometheusQuery = { ...@@ -975,7 +979,7 @@ export const graphDataPrometheusQuery = {
export const graphDataPrometheusQueryRange = { export const graphDataPrometheusQueryRange = {
title: 'Super Chart A1', title: 'Super Chart A1',
type: 'area', type: 'area-chart',
weight: 2, weight: 2,
metrics: [ metrics: [
{ {
......
...@@ -2,7 +2,9 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,7 +2,9 @@ import { shallowMount } from '@vue/test-utils';
import PanelType from '~/monitoring/components/panel_type.vue'; import PanelType from '~/monitoring/components/panel_type.vue';
import EmptyChart from '~/monitoring/components/charts/empty_chart.vue'; import EmptyChart from '~/monitoring/components/charts/empty_chart.vue';
import TimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; import TimeSeriesChart from '~/monitoring/components/charts/time_series.vue';
import AnomalyChart from '~/monitoring/components/charts/anomaly.vue';
import { graphDataPrometheusQueryRange } from './mock_data'; import { graphDataPrometheusQueryRange } from './mock_data';
import { anomalyMockGraphData } from '../../frontend/monitoring/mock_data';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
describe('Panel Type component', () => { describe('Panel Type component', () => {
...@@ -49,17 +51,20 @@ describe('Panel Type component', () => { ...@@ -49,17 +51,20 @@ describe('Panel Type component', () => {
describe('when Graph data is available', () => { describe('when Graph data is available', () => {
const exampleText = 'example_text'; const exampleText = 'example_text';
const propsData = {
clipboardText: exampleText,
dashboardWidth,
graphData: graphDataPrometheusQueryRange,
};
beforeEach(() => { beforeEach(done => {
store = createStore(); store = createStore();
panelType = shallowMount(PanelType, { panelType = shallowMount(PanelType, {
propsData: { propsData,
clipboardText: exampleText, sync: false,
dashboardWidth,
graphData: graphDataPrometheusQueryRange,
},
store, store,
}); });
panelType.vm.$nextTick(done);
}); });
describe('Time Series Chart panel type', () => { describe('Time Series Chart panel type', () => {
...@@ -75,5 +80,19 @@ describe('Panel Type component', () => { ...@@ -75,5 +80,19 @@ describe('Panel Type component', () => {
expect(clipboardText()).toBe(exampleText); expect(clipboardText()).toBe(exampleText);
}); });
}); });
describe('Anomaly Chart panel type', () => {
beforeEach(done => {
panelType.setProps({
graphData: anomalyMockGraphData,
});
panelType.vm.$nextTick(done);
});
it('is rendered with an anomaly chart', () => {
expect(panelType.find(AnomalyChart).isVueInstance()).toBe(true);
expect(panelType.find(AnomalyChart).exists()).toBe(true);
});
});
}); });
}); });
...@@ -7,9 +7,14 @@ import { ...@@ -7,9 +7,14 @@ import {
stringToISODate, stringToISODate,
ISODateToString, ISODateToString,
isValidDate, isValidDate,
graphDataValidatorForAnomalyValues,
} from '~/monitoring/utils'; } from '~/monitoring/utils';
import { timeWindows, timeWindowsKeyNames } from '~/monitoring/constants'; import { timeWindows, timeWindowsKeyNames } from '~/monitoring/constants';
import { graphDataPrometheusQuery, graphDataPrometheusQueryRange } from './mock_data'; import {
graphDataPrometheusQuery,
graphDataPrometheusQueryRange,
anomalyMockGraphData,
} from './mock_data';
describe('getTimeDiff', () => { describe('getTimeDiff', () => {
function secondsBetween({ start, end }) { function secondsBetween({ start, end }) {
...@@ -307,3 +312,34 @@ describe('isDateTimePickerInputValid', () => { ...@@ -307,3 +312,34 @@ describe('isDateTimePickerInputValid', () => {
}); });
}); });
}); });
describe('graphDataValidatorForAnomalyValues', () => {
let oneQuery;
let threeQueries;
let fourQueries;
beforeEach(() => {
oneQuery = graphDataPrometheusQuery;
threeQueries = anomalyMockGraphData;
const queries = [...threeQueries.queries];
queries.push(threeQueries.queries[0]);
fourQueries = {
...anomalyMockGraphData,
queries,
};
});
/*
* Anomaly charts can accept results for exactly 3 queries,
*/
it('validates passes with the right query format', () => {
expect(graphDataValidatorForAnomalyValues(threeQueries)).toBe(true);
});
it('validation fails for wrong format, 1 metric', () => {
expect(graphDataValidatorForAnomalyValues(oneQuery)).toBe(false);
});
it('validation fails for wrong format, more than 3 metrics', () => {
expect(graphDataValidatorForAnomalyValues(fourQueries)).toBe(false);
});
});
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