From f2619e21bebe388182ec282bfec78da2b2625d49 Mon Sep 17 00:00:00 2001 From: Miguel Rincon Date: Wed, 21 Aug 2019 13:43:01 +0000 Subject: [PATCH] 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 --- .../monitoring/components/charts/area.vue | 3 + .../components/charts/time_series.vue | 334 +++++++++++++++++ .../monitoring/components/dashboard.vue | 10 +- .../javascripts/monitoring/constants.js | 9 + .../65412-add-support-for-line-charts.yml | 5 + config/prometheus/common_metrics.yml | 8 +- ...05640_import_common_metrics_line_charts.rb | 13 + .../monitoring/charts/time_series_spec.js | 335 ++++++++++++++++++ spec/javascripts/monitoring/mock_data.js | 4 +- 9 files changed, 711 insertions(+), 10 deletions(-) create mode 100644 app/assets/javascripts/monitoring/components/charts/time_series.vue create mode 100644 changelogs/unreleased/65412-add-support-for-line-charts.yml create mode 100644 db/migrate/20190814205640_import_common_metrics_line_charts.rb create mode 100644 spec/javascripts/monitoring/charts/time_series_spec.js diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index 90c764587a3..78d97a3c122 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -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, diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue new file mode 100644 index 00000000000..2fdc75f63ca --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -0,0 +1,334 @@ + + + diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index ebd610af7b6..d330ceb836c 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -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 { /> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index d7d89522732..13aba3d9f44 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -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, diff --git a/changelogs/unreleased/65412-add-support-for-line-charts.yml b/changelogs/unreleased/65412-add-support-for-line-charts.yml new file mode 100644 index 00000000000..cb9043596b7 --- /dev/null +++ b/changelogs/unreleased/65412-add-support-for-line-charts.yml @@ -0,0 +1,5 @@ +--- +title: Create component to display area and line charts in monitor dashboards +merge_request: 31639 +author: +type: added diff --git a/config/prometheus/common_metrics.yml b/config/prometheus/common_metrics.yml index 32475ef8380..08504d6f7d5 100644 --- a/config/prometheus/common_metrics.yml +++ b/config/prometheus/common_metrics.yml @@ -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: diff --git a/db/migrate/20190814205640_import_common_metrics_line_charts.rb b/db/migrate/20190814205640_import_common_metrics_line_charts.rb new file mode 100644 index 00000000000..1c28d686a42 --- /dev/null +++ b/db/migrate/20190814205640_import_common_metrics_line_charts.rb @@ -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 diff --git a/spec/javascripts/monitoring/charts/time_series_spec.js b/spec/javascripts/monitoring/charts/time_series_spec.js new file mode 100644 index 00000000000..d145a64e8d0 --- /dev/null +++ b/spec/javascripts/monitoring/charts/time_series_spec.js @@ -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(); + }); + }); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js index 85e660d3925..17e7314e214 100644 --- a/spec/javascripts/monitoring/mock_data.js +++ b/spec/javascripts/monitoring/mock_data.js @@ -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: [