227 lines
6.8 KiB
Vue
227 lines
6.8 KiB
Vue
<script>
|
|
import { flattenDeep, isNumber } from 'lodash';
|
|
import { 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 metrics, 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: {
|
|
GlChartSeriesLabel,
|
|
MonitorTimeSeriesChart,
|
|
},
|
|
inheritAttrs: false,
|
|
props: {
|
|
graphData: {
|
|
type: Object,
|
|
required: true,
|
|
validator: graphDataValidatorForAnomalyValues,
|
|
},
|
|
},
|
|
computed: {
|
|
series() {
|
|
return this.graphData.metrics.map(metric => {
|
|
const values = metric.result && metric.result[0] ? metric.result[0].values : [];
|
|
return {
|
|
label: metric.label,
|
|
// NaN values may disrupt avg., max. & min. calculations in the legend, filter them out
|
|
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 = flattenDeep(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.metrics[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',
|
|
metrics: [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 = {
|
|
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>
|