This commit adds a new time series component

Adds a time series component for line and area charts.
Displays new charts in the dashboard.

- Use dynamic components for line/area swapping
- Add new line charts to dashboard in 2 panels
This commit is contained in:
Miguel Rincon 2019-08-21 13:43:01 +00:00 committed by Kushal Pandya
parent 0a4d4c0a58
commit f2619e21be
9 changed files with 711 additions and 10 deletions

View file

@ -12,6 +12,9 @@ import { graphDataValidatorForValues } from '../../utils';
let debouncedResize;
// TODO: Remove this component in favor of the more general time_series.vue
// Please port all changes here to time_series.vue as well.
export default {
components: {
GlAreaChart,

View file

@ -0,0 +1,334 @@
<script>
import { __ } from '~/locale';
import { mapState } from 'vuex';
import { GlLink, GlButton } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
import { chartHeight, graphTypes, lineTypes, symbolSizes, dateFormats } from '../../constants';
import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils';
let debouncedResize;
export default {
components: {
GlAreaChart,
GlLineChart,
GlButton,
GlChartSeriesLabel,
GlLink,
Icon,
},
inheritAttrs: false,
props: {
graphData: {
type: Object,
required: true,
validator: graphDataValidatorForValues.bind(null, false),
},
containerWidth: {
type: Number,
required: true,
},
deploymentData: {
type: Array,
required: false,
default: () => [],
},
projectPath: {
type: String,
required: false,
default: '',
},
showBorder: {
type: Boolean,
required: false,
default: false,
},
thresholds: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
tooltip: {
title: '',
content: [],
commitUrl: '',
isDeployment: false,
sha: '',
},
width: 0,
height: chartHeight,
svgs: {},
primaryColor: null,
};
},
computed: {
...mapState('monitoringDashboard', ['exportMetricsToCsvEnabled']),
chartData() {
// Transforms & supplements query data to render appropriate labels & styles
// Input: [{ queryAttributes1 }, { queryAttributes2 }]
// Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
return this.graphData.queries.reduce((acc, query) => {
const { appearance } = query;
const lineType =
appearance && appearance.line && appearance.line.type
? appearance.line.type
: lineTypes.default;
const lineWidth =
appearance && appearance.line && appearance.line.width
? appearance.line.width
: undefined;
const areaStyle = {
opacity:
appearance && appearance.area && typeof appearance.area.opacity === 'number'
? appearance.area.opacity
: undefined,
};
const series = makeDataSeries(query.result, {
name: this.formatLegendLabel(query),
lineStyle: {
type: lineType,
width: lineWidth,
},
showSymbol: false,
areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined,
});
return acc.concat(series);
}, []);
},
chartOptions() {
return {
xAxis: {
name: __('Time'),
type: 'time',
axisLabel: {
formatter: date => dateFormat(date, dateFormats.timeOfDay),
},
axisPointer: {
snap: true,
},
},
yAxis: {
name: this.yAxisLabel,
axisLabel: {
formatter: num => roundOffFloat(num, 3).toString(),
},
},
series: this.scatterSeries,
dataZoom: this.dataZoomConfig,
};
},
dataZoomConfig() {
const handleIcon = this.svgs['scroll-handle'];
return handleIcon ? { handleIcon } : {};
},
earliestDatapoint() {
return this.chartData.reduce((acc, series) => {
const { data } = series;
const { length } = data;
if (!length) {
return acc;
}
const [first] = data[0];
const [last] = data[length - 1];
const seriesEarliest = first < last ? first : last;
return seriesEarliest < acc || acc === null ? seriesEarliest : acc;
}, null);
},
glChartComponent() {
const chartTypes = {
'area-chart': GlAreaChart,
'line-chart': GlLineChart,
};
return chartTypes[this.graphData.type] || GlAreaChart;
},
isMultiSeries() {
return this.tooltip.content.length > 1;
},
recentDeployments() {
return this.deploymentData.reduce((acc, deployment) => {
if (deployment.created_at >= this.earliestDatapoint) {
const { id, created_at, sha, ref, tag } = deployment;
acc.push({
id,
createdAt: created_at,
sha,
commitUrl: `${this.projectPath}/commit/${sha}`,
tag,
tagUrl: tag ? `${this.tagsPath}/${ref.name}` : null,
ref: ref.name,
showDeploymentFlag: false,
});
}
return acc;
}, []);
},
scatterSeries() {
return {
type: graphTypes.deploymentData,
data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]),
symbol: this.svgs.rocket,
symbolSize: symbolSizes.default,
itemStyle: {
color: this.primaryColor,
},
};
},
yAxisLabel() {
return `${this.graphData.y_label}`;
},
csvText() {
const chartData = this.chartData[0].data;
const header = `timestamp,${this.graphData.y_label}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
return chartData.reduce((csv, data) => {
const row = data.join(',');
return `${csv}${row}\r\n`;
}, header);
},
downloadLink() {
const data = new Blob([this.csvText], { type: 'text/plain' });
return window.URL.createObjectURL(data);
},
},
watch: {
containerWidth: 'onResize',
},
beforeDestroy() {
window.removeEventListener('resize', debouncedResize);
},
created() {
debouncedResize = debounceByAnimationFrame(this.onResize);
window.addEventListener('resize', debouncedResize);
this.setSvg('rocket');
this.setSvg('scroll-handle');
},
methods: {
formatLegendLabel(query) {
return `${query.label}`;
},
formatTooltipText(params) {
this.tooltip.title = dateFormat(params.value, dateFormats.default);
this.tooltip.content = [];
params.seriesData.forEach(dataPoint => {
const [xVal, yVal] = dataPoint.value;
this.tooltip.isDeployment = dataPoint.componentSubType === graphTypes.deploymentData;
if (this.tooltip.isDeployment) {
const [deploy] = this.recentDeployments.filter(
deployment => deployment.createdAt === xVal,
);
this.tooltip.sha = deploy.sha.substring(0, 8);
this.tooltip.commitUrl = deploy.commitUrl;
} else {
const { seriesName, color } = dataPoint;
const value = yVal.toFixed(3);
this.tooltip.content.push({
name: seriesName,
value,
color,
});
}
});
},
setSvg(name) {
getSvgIconPathContent(name)
.then(path => {
if (path) {
this.$set(this.svgs, name, `path://${path}`);
}
})
.catch(e => {
// eslint-disable-next-line no-console, @gitlab/i18n/no-non-i18n-strings
console.error('SVG could not be rendered correctly: ', e);
});
},
onChartUpdated(chart) {
[this.primaryColor] = chart.getOption().color;
},
onResize() {
if (!this.$refs.chart) return;
const { width } = this.$refs.chart.$el.getBoundingClientRect();
this.width = width;
},
},
};
</script>
<template>
<div class="prometheus-graph col-12 col-lg-6" :class="[showBorder ? 'p-2' : 'p-0']">
<div :class="{ 'prometheus-graph-embed w-100 p-3': showBorder }">
<div class="prometheus-graph-header">
<h5 class="prometheus-graph-title js-graph-title">{{ graphData.title }}</h5>
<gl-button
v-if="exportMetricsToCsvEnabled"
:href="downloadLink"
:title="__('Download CSV')"
:aria-label="__('Download CSV')"
style="margin-left: 200px;"
download="chart_metrics.csv"
>
{{ __('Download CSV') }}
</gl-button>
<div class="prometheus-graph-widgets js-graph-widgets">
<slot></slot>
</div>
</div>
<component
:is="glChartComponent"
ref="chart"
v-bind="$attrs"
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
:thresholds="thresholds"
:width="width"
:height="height"
@updated="onChartUpdated"
>
<template v-if="tooltip.isDeployment">
<template slot="tooltipTitle">
{{ __('Deployed') }}
</template>
<div slot="tooltipContent" class="d-flex align-items-center">
<icon name="commit" class="mr-2" />
<gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</div>
</template>
<template v-else>
<template slot="tooltipTitle">
<div class="text-nowrap">
{{ tooltip.title }}
</div>
</template>
<template slot="tooltipContent">
<div
v-for="(content, key) in tooltip.content"
:key="key"
class="d-flex justify-content-between"
>
<gl-chart-series-label :color="isMultiSeries ? content.color : ''">
{{ content.name }}
</gl-chart-series-label>
<div class="prepend-left-32">
{{ content.value }}
</div>
</div>
</template>
</template>
</component>
</div>
</div>
</template>

View file

@ -15,7 +15,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import MonitorAreaChart from './charts/area.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
@ -26,7 +26,7 @@ let sidebarMutationObserver;
export default {
components: {
MonitorAreaChart,
MonitorTimeSeriesChart,
MonitorSingleStatChart,
PanelType,
GraphGroup,
@ -465,7 +465,7 @@ export default {
/>
</template>
<template v-else>
<monitor-area-chart
<monitor-time-series-chart
v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)"
:key="graphIndex"
:graph-data="graphData"
@ -473,7 +473,7 @@ export default {
:thresholds="getGraphAlertValues(graphData.queries)"
:container-width="elWidth"
:project-path="projectPath"
group-id="monitor-area-chart"
group-id="monitor-time-series-chart"
>
<div class="d-flex align-items-center">
<alert-widget
@ -515,7 +515,7 @@ export default {
</gl-dropdown-item>
</gl-dropdown>
</div>
</monitor-area-chart>
</monitor-time-series-chart>
</template>
</graph-group>
</div>

View file

@ -8,6 +8,10 @@ export const graphTypes = {
deploymentData: 'scatter',
};
export const symbolSizes = {
default: 14,
};
export const lineTypes = {
default: 'solid',
};
@ -21,6 +25,11 @@ export const timeWindows = {
oneWeek: __('1 week'),
};
export const dateFormats = {
timeOfDay: 'h:MM TT',
default: 'dd mmm yyyy, h:MMTT',
};
export const secondsIn = {
thirtyMinutes: 60 * 30,
threeHours: 60 * 60 * 3,

View file

@ -0,0 +1,5 @@
---
title: Create component to display area and line charts in monitor dashboards
merge_request: 31639
author:
type: added

View file

@ -166,7 +166,7 @@ panel_groups:
label: Total (cores)
unit: "cores"
- title: "Memory Usage (Pod average)"
type: "area-chart"
type: "line-chart"
y_label: "Memory Used per Pod (MB)"
weight: 2
metrics:
@ -175,7 +175,7 @@ panel_groups:
label: Pod average (MB)
unit: MB
- title: "Canary: Memory Usage (Pod Average)"
type: "area-chart"
type: "line-chart"
y_label: "Memory Used per Pod (MB)"
weight: 2
metrics:
@ -185,7 +185,7 @@ panel_groups:
unit: MB
track: canary
- title: "Core Usage (Pod Average)"
type: "area-chart"
type: "line-chart"
y_label: "Cores per Pod"
weight: 1
metrics:
@ -194,7 +194,7 @@ panel_groups:
label: Pod average (cores)
unit: "cores"
- title: "Canary: Core Usage (Pod Average)"
type: "area-chart"
type: "line-chart"
y_label: "Cores per Pod"
weight: 1
metrics:

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class ImportCommonMetricsLineCharts < ActiveRecord::Migration[5.2]
DOWNTIME = false
def up
::Gitlab::DatabaseImporters::CommonMetrics::Importer.new.execute
end
def down
# no-op
end
end

View file

@ -0,0 +1,335 @@
import { shallowMount } from '@vue/test-utils';
import { createStore } from '~/monitoring/stores';
import { GlLink } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { shallowWrapperContainsSlotText } from 'spec/helpers/vue_test_utils_helper';
import TimeSeries from '~/monitoring/components/charts/time_series.vue';
import * as types from '~/monitoring/stores/mutation_types';
import { TEST_HOST } from 'spec/test_constants';
import MonitoringMock, { deploymentData, mockProjectPath } from '../mock_data';
describe('Time series component', () => {
const mockSha = 'mockSha';
const mockWidgets = 'mockWidgets';
const mockSvgPathContent = 'mockSvgPathContent';
const projectPath = `${TEST_HOST}${mockProjectPath}`;
const commitUrl = `${projectPath}/commit/${mockSha}`;
let mockGraphData;
let makeTimeSeriesChart;
let spriteSpy;
let store;
beforeEach(() => {
store = createStore();
store.commit(`monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, MonitoringMock.data);
store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData);
store.dispatch('monitoringDashboard/setFeatureFlags', { exportMetricsToCsvEnabled: true });
[mockGraphData] = store.state.monitoringDashboard.groups[0].metrics;
makeTimeSeriesChart = (graphData, type) =>
shallowMount(TimeSeries, {
propsData: {
graphData: { ...graphData, type },
containerWidth: 0,
deploymentData: store.state.monitoringDashboard.deploymentData,
projectPath,
},
slots: {
default: mockWidgets,
},
sync: false,
store,
});
spriteSpy = spyOnDependency(TimeSeries, 'getSvgIconPathContent').and.callFake(
() => new Promise(resolve => resolve(mockSvgPathContent)),
);
});
describe('general functions', () => {
let timeSeriesChart;
beforeEach(() => {
timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart');
});
it('renders chart title', () => {
expect(timeSeriesChart.find('.js-graph-title').text()).toBe(mockGraphData.title);
});
it('contains graph widgets from slot', () => {
expect(timeSeriesChart.find('.js-graph-widgets').text()).toBe(mockWidgets);
});
describe('when exportMetricsToCsvEnabled is disabled', () => {
beforeEach(() => {
store.dispatch('monitoringDashboard/setFeatureFlags', { exportMetricsToCsvEnabled: false });
});
it('does not render the Download CSV button', done => {
timeSeriesChart.vm.$nextTick(() => {
expect(timeSeriesChart.contains('glbutton-stub')).toBe(false);
done();
});
});
});
describe('methods', () => {
describe('formatTooltipText', () => {
const mockDate = deploymentData[0].created_at;
const mockCommitUrl = deploymentData[0].commitUrl;
const generateSeriesData = type => ({
seriesData: [
{
seriesName: timeSeriesChart.vm.chartData[0].name,
componentSubType: type,
value: [mockDate, 5.55555],
seriesIndex: 0,
},
],
value: mockDate,
});
describe('when series is of line type', () => {
beforeEach(done => {
timeSeriesChart.vm.formatTooltipText(generateSeriesData('line'));
timeSeriesChart.vm.$nextTick(done);
});
it('formats tooltip title', () => {
expect(timeSeriesChart.vm.tooltip.title).toBe('31 May 2017, 9:23PM');
});
it('formats tooltip content', () => {
const name = 'Core Usage';
const value = '5.556';
const seriesLabel = timeSeriesChart.find(GlChartSeriesLabel);
expect(seriesLabel.vm.color).toBe('');
expect(shallowWrapperContainsSlotText(seriesLabel, 'default', name)).toBe(true);
expect(timeSeriesChart.vm.tooltip.content).toEqual([{ name, value, color: undefined }]);
expect(
shallowWrapperContainsSlotText(
timeSeriesChart.find(GlAreaChart),
'tooltipContent',
value,
),
).toBe(true);
});
});
describe('when series is of scatter type', () => {
beforeEach(() => {
timeSeriesChart.vm.formatTooltipText(generateSeriesData('scatter'));
});
it('formats tooltip title', () => {
expect(timeSeriesChart.vm.tooltip.title).toBe('31 May 2017, 9:23PM');
});
it('formats tooltip sha', () => {
expect(timeSeriesChart.vm.tooltip.sha).toBe('f5bcd1d9');
});
it('formats tooltip commit url', () => {
expect(timeSeriesChart.vm.tooltip.commitUrl).toBe(mockCommitUrl);
});
});
});
describe('setSvg', () => {
const mockSvgName = 'mockSvgName';
beforeEach(done => {
timeSeriesChart.vm.setSvg(mockSvgName);
timeSeriesChart.vm.$nextTick(done);
});
it('gets svg path content', () => {
expect(spriteSpy).toHaveBeenCalledWith(mockSvgName);
});
it('sets svg path content', () => {
timeSeriesChart.vm.$nextTick(() => {
expect(timeSeriesChart.vm.svgs[mockSvgName]).toBe(`path://${mockSvgPathContent}`);
});
});
});
describe('onResize', () => {
const mockWidth = 233;
beforeEach(() => {
spyOn(Element.prototype, 'getBoundingClientRect').and.callFake(() => ({
width: mockWidth,
}));
timeSeriesChart.vm.onResize();
});
it('sets area chart width', () => {
expect(timeSeriesChart.vm.width).toBe(mockWidth);
});
});
});
describe('computed', () => {
describe('chartData', () => {
let chartData;
const seriesData = () => chartData[0];
beforeEach(() => {
({ chartData } = timeSeriesChart.vm);
});
it('utilizes all data points', () => {
const { values } = mockGraphData.queries[0].result[0];
expect(chartData.length).toBe(1);
expect(seriesData().data.length).toBe(values.length);
});
it('creates valid data', () => {
const { data } = seriesData();
expect(
data.filter(
([time, value]) => new Date(time).getTime() > 0 && typeof value === 'number',
).length,
).toBe(data.length);
});
it('formats line width correctly', () => {
expect(chartData[0].lineStyle.width).toBe(2);
});
});
describe('chartOptions', () => {
describe('yAxis formatter', () => {
let format;
beforeEach(() => {
format = timeSeriesChart.vm.chartOptions.yAxis.axisLabel.formatter;
});
it('rounds to 3 decimal places', () => {
expect(format(0.88888)).toBe('0.889');
});
});
});
describe('scatterSeries', () => {
it('utilizes deployment data', () => {
expect(timeSeriesChart.vm.scatterSeries.data).toEqual([
['2017-05-31T21:23:37.881Z', 0],
['2017-05-30T20:08:04.629Z', 0],
['2017-05-30T17:42:38.409Z', 0],
]);
expect(timeSeriesChart.vm.scatterSeries.symbolSize).toBe(14);
});
});
describe('yAxisLabel', () => {
it('constructs a label for the chart y-axis', () => {
expect(timeSeriesChart.vm.yAxisLabel).toBe('CPU');
});
});
describe('csvText', () => {
it('converts data from json to csv', () => {
const header = `timestamp,${mockGraphData.y_label}`;
const data = mockGraphData.queries[0].result[0].values;
const firstRow = `${data[0][0]},${data[0][1]}`;
expect(timeSeriesChart.vm.csvText).toMatch(`^${header}\r\n${firstRow}`);
});
});
describe('downloadLink', () => {
it('produces a link to download metrics as csv', () => {
const link = timeSeriesChart.vm.downloadLink;
expect(link).toContain('blob:');
});
});
});
afterEach(() => {
timeSeriesChart.destroy();
});
});
describe('wrapped components', () => {
const glChartComponents = [
{
chartType: 'area-chart',
component: GlAreaChart,
},
{
chartType: 'line-chart',
component: GlLineChart,
},
];
glChartComponents.forEach(dynamicComponent => {
describe(`GitLab UI: ${dynamicComponent.chartType}`, () => {
let timeSeriesAreaChart;
let glChart;
beforeEach(done => {
timeSeriesAreaChart = makeTimeSeriesChart(mockGraphData, dynamicComponent.chartType);
glChart = timeSeriesAreaChart.find(dynamicComponent.component);
timeSeriesAreaChart.vm.$nextTick(done);
});
it('is a Vue instance', () => {
expect(glChart.exists()).toBe(true);
expect(glChart.isVueInstance()).toBe(true);
});
it('receives data properties needed for proper chart render', () => {
const props = glChart.props();
expect(props.data).toBe(timeSeriesAreaChart.vm.chartData);
expect(props.option).toBe(timeSeriesAreaChart.vm.chartOptions);
expect(props.formatTooltipText).toBe(timeSeriesAreaChart.vm.formatTooltipText);
expect(props.thresholds).toBe(timeSeriesAreaChart.vm.thresholds);
});
it('recieves a tooltip title', done => {
const mockTitle = 'mockTitle';
timeSeriesAreaChart.vm.tooltip.title = mockTitle;
timeSeriesAreaChart.vm.$nextTick(() => {
expect(shallowWrapperContainsSlotText(glChart, 'tooltipTitle', mockTitle)).toBe(true);
done();
});
});
describe('when tooltip is showing deployment data', () => {
beforeEach(done => {
timeSeriesAreaChart.vm.tooltip.isDeployment = true;
timeSeriesAreaChart.vm.$nextTick(done);
});
it('uses deployment title', () => {
expect(shallowWrapperContainsSlotText(glChart, 'tooltipTitle', 'Deployed')).toBe(true);
});
it('renders clickable commit sha in tooltip content', done => {
timeSeriesAreaChart.vm.tooltip.sha = mockSha;
timeSeriesAreaChart.vm.tooltip.commitUrl = commitUrl;
timeSeriesAreaChart.vm.$nextTick(() => {
const commitLink = timeSeriesAreaChart.find(GlLink);
expect(shallowWrapperContainsSlotText(commitLink, 'default', mockSha)).toBe(true);
expect(commitLink.attributes('href')).toEqual(commitUrl);
done();
});
});
});
});
});
});
});

View file

@ -1,5 +1,7 @@
export const mockApiEndpoint = `${gl.TEST_HOST}/monitoring/mock`;
export const mockProjectPath = '/frontend-fixtures/environments-project';
export const metricsGroupsAPIResponse = {
success: true,
data: [
@ -902,7 +904,7 @@ export const metricsDashboardResponse = {
},
{
title: 'Memory Usage (Pod average)',
type: 'area-chart',
type: 'line-chart',
y_label: 'Memory Used per Pod',
weight: 2,
metrics: [