diff options
Diffstat (limited to 'spec/frontend/monitoring')
33 files changed, 2896 insertions, 1511 deletions
diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 4b08163f30a..e7c51d82cd2 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -4,22 +4,32 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="prometheus-graphs" data-qa-selector="prometheus_graphs" + environmentstate="available" + metricsdashboardbasepath="/monitoring/monitor-project/-/environments/1/metrics" + metricsendpoint="/monitoring/monitor-project/-/environments/1/additional_metrics.json" + prometheusstatus="" > <div class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" > <div - class="mb-2 pr-2 d-flex d-sm-block" + class="mb-2 mr-2 d-flex d-sm-block" > <dashboards-dropdown-stub class="flex-grow-1" data-qa-selector="dashboards_filter_dropdown" defaultbranch="master" id="monitor-dashboards-dropdown" + modalid="duplicateDashboard" toggle-class="dropdown-menu-toggle" /> </div> + <span + aria-hidden="true" + class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block" + /> + <div class="mb-2 pr-2 d-flex d-sm-block" > @@ -80,17 +90,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="mb-2 pr-2 d-flex d-sm-block" > - <gl-deprecated-button-stub - class="flex-grow-1" - size="md" - title="Refresh dashboard" - variant="default" - > - <icon-stub - name="retry" - size="16" - /> - </gl-deprecated-button-stub> + <refresh-button-stub /> </div> <div @@ -127,23 +127,30 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <!----> <!----> + + <!----> + + <!----> + + <!----> </div> + + <duplicate-dashboard-modal-stub + defaultbranch="master" + modalid="duplicateDashboard" + /> </div> - <!----> - - <!----> - <empty-state-stub - clusterspath="/path/to/clusters" - documentationpath="/path/to/docs" - emptygettingstartedsvgpath="/path/to/getting-started.svg" - emptyloadingsvgpath="/path/to/loading.svg" - emptynodatasmallsvgpath="/path/to/no-data-small.svg" - emptynodatasvgpath="/path/to/no-data.svg" - emptyunabletoconnectsvgpath="/path/to/unable-to-connect.svg" + clusterspath="/monitoring/monitor-project/-/clusters" + documentationpath="/help/administration/monitoring/prometheus/index.md" + emptygettingstartedsvgpath="/images/illustrations/monitoring/getting_started.svg" + emptyloadingsvgpath="/images/illustrations/monitoring/loading.svg" + emptynodatasmallsvgpath="/images/illustrations/chart-empty-state-small.svg" + emptynodatasvgpath="/images/illustrations/monitoring/no_data.svg" + emptyunabletoconnectsvgpath="/images/illustrations/monitoring/unable_to_connect.svg" selectedstate="gettingStarted" - settingspath="/path/to/settings" + settingspath="/monitoring/monitor-project/-/services/prometheus/edit" /> </div> `; diff --git a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap index 31b3ad1bd76..4f8a82692b8 100644 --- a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap @@ -1,37 +1,49 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`EmptyState shows gettingStarted state 1`] = ` -<gl-empty-state-stub - description="Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments." - primarybuttonlink="/clustersPath" - primarybuttontext="Install on clusters" - secondarybuttonlink="/settingsPath" - secondarybuttontext="Configure existing installation" - svgpath="/path/to/getting-started.svg" - title="Get started with performance monitoring" -/> +<div> + <!----> + + <gl-empty-state-stub + description="Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments." + primarybuttonlink="/clustersPath" + primarybuttontext="Install on clusters" + secondarybuttonlink="/settingsPath" + secondarybuttontext="Configure existing installation" + svgpath="/path/to/getting-started.svg" + title="Get started with performance monitoring" + /> +</div> `; -exports[`EmptyState shows loading state 1`] = ` -<gl-empty-state-stub - description="Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available." - primarybuttonlink="/documentationPath" - primarybuttontext="View documentation" - secondarybuttonlink="" - secondarybuttontext="" - svgpath="/path/to/loading.svg" - title="Waiting for performance data" -/> +exports[`EmptyState shows noData state 1`] = ` +<div> + <!----> + + <gl-empty-state-stub + description="You are connected to the Prometheus server, but there is currently no data to display." + primarybuttonlink="/settingsPath" + primarybuttontext="Configure Prometheus" + secondarybuttonlink="" + secondarybuttontext="" + svgpath="/path/to/no-data.svg" + title="No data found" + /> +</div> `; exports[`EmptyState shows unableToConnect state 1`] = ` -<gl-empty-state-stub - description="Ensure connectivity is available from the GitLab server to the Prometheus server" - primarybuttonlink="/documentationPath" - primarybuttontext="View documentation" - secondarybuttonlink="/settingsPath" - secondarybuttontext="Configure Prometheus" - svgpath="/path/to/unable-to-connect.svg" - title="Unable to connect to Prometheus server" -/> +<div> + <!----> + + <gl-empty-state-stub + description="Ensure connectivity is available from the GitLab server to the Prometheus server" + primarybuttonlink="/documentationPath" + primarybuttontext="View documentation" + secondarybuttonlink="/settingsPath" + secondarybuttontext="Configure Prometheus" + svgpath="/path/to/unable-to-connect.svg" + title="Unable to connect to Prometheus server" + /> +</div> `; diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js index 4178d3f0d2d..15a52d03bcd 100644 --- a/spec/frontend/monitoring/components/charts/anomaly_spec.js +++ b/spec/frontend/monitoring/components/charts/anomaly_spec.js @@ -3,28 +3,14 @@ import { TEST_HOST } from 'helpers/test_constants'; import Anomaly from '~/monitoring/components/charts/anomaly.vue'; import { colorValues } from '~/monitoring/constants'; -import { - anomalyDeploymentData, - mockProjectDir, - anomalyMockGraphData, - anomalyMockResultValues, -} from '../../mock_data'; +import { anomalyDeploymentData, mockProjectDir } from '../../mock_data'; +import { anomalyGraphData } from '../../graph_data'; import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; const mockProjectPath = `${TEST_HOST}${mockProjectDir}`; -const makeAnomalyGraphData = (datasetName, template = anomalyMockGraphData) => { - const metrics = anomalyMockResultValues[datasetName].map((values, index) => ({ - ...template.metrics[index], - result: [ - { - metrics: {}, - values, - }, - ], - })); - return { ...template, metrics }; -}; +const TEST_UPPER = 11; +const TEST_LOWER = 9; describe('Anomaly chart component', () => { let wrapper; @@ -38,13 +24,22 @@ describe('Anomaly chart component', () => { const getTimeSeriesProps = () => findTimeSeries().props(); describe('wrapped monitor-time-series-chart component', () => { - const dataSetName = 'noAnomaly'; - const dataSet = anomalyMockResultValues[dataSetName]; + const mockValues = ['10', '10', '10']; + + const mockGraphData = anomalyGraphData( + {}, + { + upper: mockValues.map(() => String(TEST_UPPER)), + values: mockValues, + lower: mockValues.map(() => String(TEST_LOWER)), + }, + ); + const inputThresholds = ['some threshold']; beforeEach(() => { setupAnomalyChart({ - graphData: makeAnomalyGraphData(dataSetName), + graphData: mockGraphData, deploymentData: anomalyDeploymentData, thresholds: inputThresholds, projectPath: mockProjectPath, @@ -65,21 +60,21 @@ describe('Anomaly chart component', () => { it('receives "metric" with all data', () => { const { graphData } = getTimeSeriesProps(); - const query = graphData.metrics[0]; - const expectedQuery = makeAnomalyGraphData(dataSetName).metrics[0]; - expect(query).toEqual(expectedQuery); + const metric = graphData.metrics[0]; + const expectedMetric = mockGraphData.metrics[0]; + expect(metric).toEqual(expectedMetric); }); it('receives the "metric" results', () => { const { graphData } = getTimeSeriesProps(); const { result } = graphData.metrics[0]; const { values } = result[0]; - const [metricDataset] = dataSet; - expect(values).toEqual(expect.any(Array)); - values.forEach(([, y], index) => { - expect(y).toBeCloseTo(metricDataset[index][1]); - }); + expect(values).toEqual([ + [expect.any(String), 10], + [expect.any(String), 10], + [expect.any(String), 10], + ]); }); }); @@ -108,14 +103,13 @@ describe('Anomaly chart component', () => { 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]); + lowerSeries.data.forEach(([, y]) => { + expect(y).toBeCloseTo(TEST_LOWER); }); - upperSeries.data.forEach(([, y], i) => { - expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]); + upperSeries.data.forEach(([, y]) => { + expect(y).toBeCloseTo(TEST_UPPER - TEST_LOWER); }); }); }); @@ -140,11 +134,10 @@ describe('Anomaly chart component', () => { }), ); }); + it('does not display anomalies', () => { const { symbolSize, itemStyle } = seriesConfig; - const [metricDataset] = dataSet; - - metricDataset.forEach((v, dataIndex) => { + mockValues.forEach((v, dataIndex) => { const size = symbolSize(null, { dataIndex }); const color = itemStyle.color({ dataIndex }); @@ -155,9 +148,10 @@ describe('Anomaly chart component', () => { }); 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]); + mockValues.forEach((v, dataIndex) => { + const formatted = wrapper.vm.yValueFormatted(0, dataIndex); + expect(parseFloat(formatted)).toEqual(parseFloat(v)); + }); }); }); @@ -179,12 +173,18 @@ describe('Anomaly chart component', () => { }); describe('with no boundary data', () => { - const dataSetName = 'noBoundary'; - const dataSet = anomalyMockResultValues[dataSetName]; + const noBoundaryData = anomalyGraphData( + {}, + { + upper: [], + values: ['10', '10', '10'], + lower: [], + }, + ); beforeEach(() => { setupAnomalyChart({ - graphData: makeAnomalyGraphData(dataSetName), + graphData: noBoundaryData, deploymentData: anomalyDeploymentData, }); }); @@ -204,7 +204,7 @@ describe('Anomaly chart component', () => { }); 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(0, 0))).toEqual(10); expect(wrapper.vm.yValueFormatted(1, 0)).toBe(''); // missing boundary expect(wrapper.vm.yValueFormatted(2, 0)).toBe(''); // missing boundary }); @@ -212,12 +212,20 @@ describe('Anomaly chart component', () => { }); describe('with one anomaly', () => { - const dataSetName = 'oneAnomaly'; - const dataSet = anomalyMockResultValues[dataSetName]; + const mockValues = ['10', '20', '10']; + + const oneAnomalyData = anomalyGraphData( + {}, + { + upper: mockValues.map(() => TEST_UPPER), + values: mockValues, + lower: mockValues.map(() => TEST_LOWER), + }, + ); beforeEach(() => { setupAnomalyChart({ - graphData: makeAnomalyGraphData(dataSetName), + graphData: oneAnomalyData, deploymentData: anomalyDeploymentData, }); }); @@ -226,13 +234,12 @@ describe('Anomaly chart component', () => { it('displays one anomaly', () => { const { seriesConfig } = getTimeSeriesProps(); const { symbolSize, itemStyle } = seriesConfig; - const [metricDataset] = dataSet; - const bigDots = metricDataset.filter((v, dataIndex) => { + const bigDots = mockValues.filter((v, dataIndex) => { const size = symbolSize(null, { dataIndex }); return size > 0.1; }); - const redDots = metricDataset.filter((v, dataIndex) => { + const redDots = mockValues.filter((v, dataIndex) => { const color = itemStyle.color({ dataIndex }); return color === colorValues.anomalySymbol; }); @@ -244,13 +251,21 @@ describe('Anomaly chart component', () => { }); describe('with offset', () => { - const dataSetName = 'negativeBoundary'; - const dataSet = anomalyMockResultValues[dataSetName]; - const expectedOffset = 4; // Lowst point in mock data is -3.70, it gets rounded + const mockValues = ['10', '11', '12']; + const mockUpper = ['20', '20', '20']; + const mockLower = ['-1', '-2', '-3.70']; + const expectedOffset = 4; // Lowest point in mock data is -3.70, it gets rounded beforeEach(() => { setupAnomalyChart({ - graphData: makeAnomalyGraphData(dataSetName), + graphData: anomalyGraphData( + {}, + { + upper: mockUpper, + values: mockValues, + lower: mockLower, + }, + ), deploymentData: anomalyDeploymentData, }); }); @@ -266,11 +281,11 @@ describe('Anomaly chart component', () => { const { graphData } = getTimeSeriesProps(); const { result } = graphData.metrics[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); + expect(y).toBeCloseTo(parseFloat(mockValues[index]) + expectedOffset); }); }); }); @@ -281,14 +296,12 @@ describe('Anomaly chart component', () => { 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); + expect(y).toBeCloseTo(parseFloat(mockLower[i]) + expectedOffset); }); upperSeries.data.forEach(([, y], i) => { - expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]); + expect(y).toBeCloseTo(parseFloat(mockUpper[i] - mockLower[i])); }); }); }); diff --git a/spec/frontend/monitoring/components/charts/column_spec.js b/spec/frontend/monitoring/components/charts/column_spec.js index 89739a7485d..a2056d96dcf 100644 --- a/spec/frontend/monitoring/components/charts/column_spec.js +++ b/spec/frontend/monitoring/components/charts/column_spec.js @@ -63,8 +63,8 @@ describe('Column component', () => { return formatter(date); }; - it('x-axis is formatted correctly in AM/PM format', () => { - expect(useXAxisFormatter(mockDate)).toEqual('8:00 PM'); + it('x-axis is formatted correctly in m/d h:MM TT format', () => { + expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); }); describe('when in PT timezone', () => { @@ -78,17 +78,17 @@ describe('Column component', () => { it('by default, values are formatted in PT', () => { createWrapper(); - expect(useXAxisFormatter(mockDate)).toEqual('1:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); }); it('when the chart uses local timezone, y-axis is formatted in PT', () => { createWrapper({ timezone: 'LOCAL' }); - expect(useXAxisFormatter(mockDate)).toEqual('1:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); }); it('when the chart uses UTC, y-axis is formatted in UTC', () => { createWrapper({ timezone: 'UTC' }); - expect(useXAxisFormatter(mockDate)).toEqual('8:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); }); }); }); diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js index 9cc5970da82..3783b1eebd2 100644 --- a/spec/frontend/monitoring/components/charts/single_stat_spec.js +++ b/spec/frontend/monitoring/components/charts/single_stat_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import SingleStatChart from '~/monitoring/components/charts/single_stat.vue'; -import { singleStatMetricsResult } from '../../mock_data'; +import { singleStatGraphData } from '../../graph_data'; describe('Single Stat Chart component', () => { let singleStatChart; @@ -8,7 +8,7 @@ describe('Single Stat Chart component', () => { beforeEach(() => { singleStatChart = shallowMount(SingleStatChart, { propsData: { - graphData: singleStatMetricsResult, + graphData: singleStatGraphData({}, { unit: 'MB' }), }, }); }); @@ -20,15 +20,12 @@ describe('Single Stat Chart component', () => { describe('computed', () => { describe('statValue', () => { it('should interpolate the value and unit props', () => { - expect(singleStatChart.vm.statValue).toBe('91.00MB'); + expect(singleStatChart.vm.statValue).toBe('1.00MB'); }); it('should change the value representation to a percentile one', () => { singleStatChart.setProps({ - graphData: { - ...singleStatMetricsResult, - maxValue: 120, - }, + graphData: singleStatGraphData({ max_value: 120 }, { value: 91 }), }); expect(singleStatChart.vm.statValue).toContain('75.83%'); @@ -36,10 +33,7 @@ describe('Single Stat Chart component', () => { it('should display NaN for non numeric maxValue values', () => { singleStatChart.setProps({ - graphData: { - ...singleStatMetricsResult, - maxValue: 'not a number', - }, + graphData: singleStatGraphData({ max_value: 'not a number' }), }); expect(singleStatChart.vm.statValue).toContain('NaN'); @@ -47,25 +41,33 @@ describe('Single Stat Chart component', () => { it('should display NaN for missing query values', () => { singleStatChart.setProps({ - graphData: { - ...singleStatMetricsResult, - metrics: [ - { - ...singleStatMetricsResult.metrics[0], - result: [ - { - ...singleStatMetricsResult.metrics[0].result[0], - value: [''], - }, - ], - }, - ], - maxValue: 120, - }, + graphData: singleStatGraphData({ max_value: 120 }, { value: 'NaN' }), }); expect(singleStatChart.vm.statValue).toContain('NaN'); }); + + describe('field attribute', () => { + it('displays a label value instead of metric value when field attribute is used', () => { + singleStatChart.setProps({ + graphData: singleStatGraphData({ field: 'job' }, { isVector: true }), + }); + + return singleStatChart.vm.$nextTick(() => { + expect(singleStatChart.vm.statValue).toContain('prometheus'); + }); + }); + + it('displays No data to display if field attribute is not present', () => { + singleStatChart.setProps({ + graphData: singleStatGraphData({ field: 'this-does-not-exist' }), + }); + + return singleStatChart.vm.$nextTick(() => { + expect(singleStatChart.vm.statValue).toContain('No data to display'); + }); + }); + }); }); }); }); diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 50d2c9c80b2..97386be9e32 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -9,18 +9,12 @@ import { GlChartSeriesLabel, GlChartLegend, } from '@gitlab/ui/dist/charts'; -import { cloneDeep } from 'lodash'; import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper'; -import { createStore } from '~/monitoring/stores'; import { panelTypes, chartHeight } from '~/monitoring/constants'; import TimeSeries from '~/monitoring/components/charts/time_series.vue'; -import * as types from '~/monitoring/stores/mutation_types'; import { deploymentData, mockProjectDir, annotationsData } from '../../mock_data'; -import { - metricsDashboardPayload, - metricsDashboardViewModel, - metricResultStatus, -} from '../../fixture_data'; + +import { timeSeriesGraphData } from '../../graph_data'; jest.mock('lodash/throttle', () => // this throttle mock executes immediately @@ -35,23 +29,21 @@ jest.mock('~/lib/utils/icon_utils', () => ({ })); describe('Time series component', () => { - let mockGraphData; - let store; + const defaultGraphData = timeSeriesGraphData(); let wrapper; const createWrapper = ( - { graphData = mockGraphData, ...props } = {}, + { graphData = defaultGraphData, ...props } = {}, mountingMethod = shallowMount, ) => { wrapper = mountingMethod(TimeSeries, { propsData: { graphData, - deploymentData: store.state.monitoringDashboard.deploymentData, - annotations: store.state.monitoringDashboard.annotations, + deploymentData, + annotations: annotationsData, projectPath: `${TEST_HOST}${mockProjectDir}`, ...props, }, - store, stubs: { GlPopover: true, }, @@ -59,27 +51,15 @@ describe('Time series component', () => { }); }; - describe('With a single time series', () => { - beforeEach(() => { - setTestTimeout(1000); - - store = createStore(); - - store.commit( - `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, - metricsDashboardPayload, - ); - - store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData); + beforeEach(() => { + setTestTimeout(1000); + }); - store.commit( - `monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, - metricResultStatus, - ); - // dashboard is a dynamically generated fixture and stored at environment_metrics_dashboard.json - [mockGraphData] = store.state.monitoringDashboard.dashboard.panelGroups[1].panels; - }); + afterEach(() => { + wrapper.destroy(); + }); + describe('With a single time series', () => { describe('general functions', () => { const findChart = () => wrapper.find({ ref: 'chart' }); @@ -88,10 +68,6 @@ describe('Time series component', () => { return wrapper.vm.$nextTick(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('allows user to override legend label texts using props', () => { const legendRelatedProps = { legendMinText: 'legendMinText', @@ -231,19 +207,20 @@ describe('Time series component', () => { }); it('formats tooltip content', () => { - const name = 'Status Code'; + const name = 'Metric 1'; const value = '5.556'; const dataIndex = 0; const seriesLabel = wrapper.find(GlChartSeriesLabel); expect(seriesLabel.vm.color).toBe(''); + expect(shallowWrapperContainsSlotText(seriesLabel, 'default', name)).toBe(true); expect(wrapper.vm.tooltip.content).toEqual([ { name, value, dataIndex, color: undefined }, ]); expect( - shallowWrapperContainsSlotText(wrapper.find(GlAreaChart), 'tooltipContent', value), + shallowWrapperContainsSlotText(wrapper.find(GlLineChart), 'tooltipContent', value), ).toBe(true); }); @@ -385,10 +362,8 @@ describe('Time series component', () => { }); it('utilizes all data points', () => { - const { values } = mockGraphData.metrics[0].result[0]; - expect(chartData.length).toBe(1); - expect(seriesData().data.length).toBe(values.length); + expect(seriesData().data.length).toBe(3); }); it('creates valid data', () => { @@ -552,8 +527,8 @@ describe('Time series component', () => { return formatter(date); }; - it('x-axis is formatted correctly in AM/PM format', () => { - expect(useXAxisFormatter(mockDate)).toEqual('8:00 PM'); + it('x-axis is formatted correctly in m/d h:MM TT format', () => { + expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); }); describe('when in PT timezone', () => { @@ -567,17 +542,17 @@ describe('Time series component', () => { it('by default, values are formatted in PT', () => { createWrapper(); - expect(useXAxisFormatter(mockDate)).toEqual('1:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); }); it('when the chart uses local timezone, y-axis is formatted in PT', () => { createWrapper({ timezone: 'LOCAL' }); - expect(useXAxisFormatter(mockDate)).toEqual('1:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 1:00 PM'); }); it('when the chart uses UTC, y-axis is formatted in UTC', () => { createWrapper({ timezone: 'UTC' }); - expect(useXAxisFormatter(mockDate)).toEqual('8:00 PM'); + expect(useXAxisFormatter(mockDate)).toEqual('5/26 8:00 PM'); }); }); }); @@ -602,14 +577,10 @@ describe('Time series component', () => { it('constructs a label for the chart y-axis', () => { const { yAxis } = getChartOptions(); - expect(yAxis[0].name).toBe('Requests / Sec'); + expect(yAxis[0].name).toBe('Y Axis'); }); }); }); - - afterEach(() => { - wrapper.destroy(); - }); }); describe('wrapped components', () => { @@ -630,7 +601,7 @@ describe('Time series component', () => { beforeEach(() => { createWrapper( - { graphData: { ...mockGraphData, type: dynamicComponent.chartType } }, + { graphData: timeSeriesGraphData({ type: dynamicComponent.chartType }) }, mount, ); return wrapper.vm.$nextTick(); @@ -700,20 +671,12 @@ describe('Time series component', () => { describe('with multiple time series', () => { describe('General functions', () => { beforeEach(() => { - store = createStore(); - const graphData = cloneDeep(metricsDashboardViewModel.panelGroups[0].panels[3]); - graphData.metrics.forEach(metric => - Object.assign(metric, { result: metricResultStatus.result }), - ); + const graphData = timeSeriesGraphData({ type: panelTypes.AREA_CHART, multiMetric: true }); - createWrapper({ graphData: { ...graphData, type: 'area-chart' } }, mount); + createWrapper({ graphData }, mount); return wrapper.vm.$nextTick(); }); - afterEach(() => { - wrapper.destroy(); - }); - describe('Color match', () => { let lineColors; @@ -754,14 +717,10 @@ describe('Time series component', () => { const findLegend = () => wrapper.find(GlChartLegend); beforeEach(() => { - createWrapper(mockGraphData, mount); + createWrapper({}, mount); return wrapper.vm.$nextTick(); }); - afterEach(() => { - wrapper.destroy(); - }); - it('should render a tabular legend layout by default', () => { expect(findLegend().props('layout')).toBe('table'); }); diff --git a/spec/frontend/monitoring/components/create_dashboard_modal_spec.js b/spec/frontend/monitoring/components/create_dashboard_modal_spec.js new file mode 100644 index 00000000000..d1028445638 --- /dev/null +++ b/spec/frontend/monitoring/components/create_dashboard_modal_spec.js @@ -0,0 +1,48 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import CreateDashboardModal from '~/monitoring/components/create_dashboard_modal.vue'; + +describe('Create dashboard modal', () => { + let wrapper; + + const defaultProps = { + modalId: 'id', + projectPath: 'https://localhost/', + addDashboardDocumentationPath: 'https://link/to/docs', + }; + + const findDocsButton = () => wrapper.find('[data-testid="create-dashboard-modal-docs-button"]'); + const findRepoButton = () => wrapper.find('[data-testid="create-dashboard-modal-repo-button"]'); + + const createWrapper = (props = {}, options = {}) => { + wrapper = shallowMount(CreateDashboardModal, { + propsData: { ...defaultProps, ...props }, + stubs: { + GlModal, + }, + ...options, + }); + }; + + beforeEach(() => { + createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has button that links to the project url', () => { + findRepoButton().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findRepoButton().exists()).toBe(true); + expect(findRepoButton().attributes('href')).toBe(defaultProps.projectPath); + }); + }); + + it('has button that links to the docs', () => { + expect(findDocsButton().exists()).toBe(true); + expect(findDocsButton().attributes('href')).toBe(defaultProps.addDashboardDocumentationPath); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboard_header_spec.js b/spec/frontend/monitoring/components/dashboard_header_spec.js new file mode 100644 index 00000000000..5a1a615c703 --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_header_spec.js @@ -0,0 +1,232 @@ +import { shallowMount } from '@vue/test-utils'; +import { createStore } from '~/monitoring/stores'; +import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; +import DuplicateDashboardModal from '~/monitoring/components/duplicate_dashboard_modal.vue'; +import CreateDashboardModal from '~/monitoring/components/create_dashboard_modal.vue'; +import { setupAllDashboards } from '../store_utils'; +import { + dashboardGitResponse, + selfMonitoringDashboardGitResponse, + dashboardHeaderProps, +} from '../mock_data'; +import { redirectTo } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), + queryToObject: jest.fn(), + mergeUrlParams: jest.requireActual('~/lib/utils/url_utility').mergeUrlParams, +})); + +describe('Dashboard header', () => { + let store; + let wrapper; + + const findActionsMenu = () => wrapper.find('[data-testid="actions-menu"]'); + const findCreateDashboardMenuItem = () => + findActionsMenu().find('[data-testid="action-create-dashboard"]'); + const findCreateDashboardDuplicateItem = () => + findActionsMenu().find('[data-testid="action-duplicate-dashboard"]'); + const findDuplicateDashboardModal = () => wrapper.find(DuplicateDashboardModal); + const findCreateDashboardModal = () => wrapper.find('[data-testid="create-dashboard-modal"]'); + + const createShallowWrapper = (props = {}, options = {}) => { + wrapper = shallowMount(DashboardHeader, { + propsData: { ...dashboardHeaderProps, ...props }, + store, + ...options, + }); + }; + + beforeEach(() => { + store = createStore(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when a dashboard has been duplicated in the duplicate dashboard modal', () => { + beforeEach(() => { + store.state.monitoringDashboard.projectPath = 'root/sandbox'; + }); + /** + * The duplicate dashboard modal gets called both by a menu item from the + * dashboards dropdown and by an item from the actions menu. + * + * This spec is context agnostic, so it addresses all cases where the + * duplicate dashboard modal gets called. + */ + it('redirects to the newly created dashboard', () => { + delete window.location; + window.location = new URL('https://localhost'); + + const newDashboard = dashboardGitResponse[1]; + + createShallowWrapper(); + + const newDashboardUrl = 'root/sandbox/-/metrics/dashboard.yml'; + findDuplicateDashboardModal().vm.$emit('dashboardDuplicated', newDashboard); + + return wrapper.vm.$nextTick().then(() => { + expect(redirectTo).toHaveBeenCalled(); + expect(redirectTo).toHaveBeenCalledWith(newDashboardUrl); + }); + }); + }); + + describe('actions menu', () => { + beforeEach(() => { + store.state.monitoringDashboard.projectPath = ''; + createShallowWrapper(); + }); + + it('is rendered if projectPath is set in store', () => { + store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().exists()).toBe(true); + }); + }); + + it('is not rendered if projectPath is not set in store', () => { + expect(findActionsMenu().exists()).toBe(false); + }); + + it('contains a modal', () => { + store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().contains(CreateDashboardModal)).toBe(true); + }); + }); + + const duplicableCases = [ + null, // When no path is specified, it uses the default dashboard path. + dashboardGitResponse[0].path, + dashboardGitResponse[2].path, + selfMonitoringDashboardGitResponse[0].path, + ]; + + describe.each(duplicableCases)( + 'when the selected dashboard can be duplicated', + dashboardPath => { + it('contains a "Create New" menu item and a "Duplicate Dashboard" menu item', () => { + store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + setupAllDashboards(store, dashboardPath); + + return wrapper.vm.$nextTick().then(() => { + expect(findCreateDashboardMenuItem().exists()).toBe(true); + expect(findCreateDashboardDuplicateItem().exists()).toBe(true); + }); + }); + }, + ); + + const nonDuplicableCases = [ + dashboardGitResponse[1].path, + selfMonitoringDashboardGitResponse[1].path, + ]; + + describe.each(nonDuplicableCases)( + 'when the selected dashboard cannot be duplicated', + dashboardPath => { + it('contains a "Create New" menu item and no "Duplicate Dashboard" menu item', () => { + store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + setupAllDashboards(store, dashboardPath); + + return wrapper.vm.$nextTick().then(() => { + expect(findCreateDashboardMenuItem().exists()).toBe(true); + expect(findCreateDashboardDuplicateItem().exists()).toBe(false); + }); + }); + }, + ); + }); + + describe('actions menu modals', () => { + const url = 'https://path/to/project'; + + beforeEach(() => { + store.state.monitoringDashboard.projectPath = url; + setupAllDashboards(store); + + createShallowWrapper(); + }); + + it('Clicking on "Create New" opens up a modal', () => { + const modalId = 'createDashboard'; + const modalTrigger = findCreateDashboardMenuItem(); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + + modalTrigger.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + }); + }); + + it('"Create new dashboard" modal contains correct buttons', () => { + expect(findCreateDashboardModal().props('projectPath')).toBe(url); + }); + + it('"Duplicate Dashboard" opens up a modal', () => { + const modalId = 'duplicateDashboard'; + const modalTrigger = findCreateDashboardDuplicateItem(); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + + modalTrigger.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + }); + }); + }); + + describe('metrics settings button', () => { + const findSettingsButton = () => wrapper.find('[data-testid="metrics-settings-button"]'); + const url = 'https://path/to/project/settings'; + + beforeEach(() => { + createShallowWrapper(); + + store.state.monitoringDashboard.canAccessOperationsSettings = false; + store.state.monitoringDashboard.operationsSettingsPath = ''; + }); + + it('is rendered when the user can access the project settings and path to settings is available', () => { + store.state.monitoringDashboard.canAccessOperationsSettings = true; + store.state.monitoringDashboard.operationsSettingsPath = url; + + return wrapper.vm.$nextTick(() => { + expect(findSettingsButton().exists()).toBe(true); + }); + }); + + it('is not rendered when the user can not access the project settings', () => { + store.state.monitoringDashboard.canAccessOperationsSettings = false; + store.state.monitoringDashboard.operationsSettingsPath = url; + + return wrapper.vm.$nextTick(() => { + expect(findSettingsButton().exists()).toBe(false); + }); + }); + + it('is not rendered when the path to settings is unavailable', () => { + store.state.monitoringDashboard.canAccessOperationsSettings = false; + store.state.monitoringDashboard.operationsSettingsPath = ''; + + return wrapper.vm.$nextTick(() => { + expect(findSettingsButton().exists()).toBe(false); + }); + }); + + it('leads to the project settings page', () => { + store.state.monitoringDashboard.canAccessOperationsSettings = true; + store.state.monitoringDashboard.operationsSettingsPath = url; + + return wrapper.vm.$nextTick(() => { + expect(findSettingsButton().attributes('href')).toBe(url); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index 0ad6e04588f..693818aa55a 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -9,17 +9,16 @@ import AlertWidget from '~/monitoring/components/alert_widget.vue'; import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { - anomalyMockGraphData, mockLogsHref, mockLogsPath, mockNamespace, mockNamespacedData, mockTimeRange, - singleStatMetricsResult, graphDataPrometheusQueryRangeMultiTrack, barMockData, - propsData, } from '../mock_data'; +import { dashboardProps, graphData, graphDataEmpty } from '../fixture_data'; +import { anomalyGraphData, singleStatGraphData } from '../graph_data'; import { panelTypes } from '~/monitoring/constants'; @@ -32,7 +31,6 @@ import MonitorColumnChart from '~/monitoring/components/charts/column.vue'; import MonitorBarChart from '~/monitoring/components/charts/bar.vue'; import MonitorStackedColumnChart from '~/monitoring/components/charts/stacked_column.vue'; -import { graphData, graphDataEmpty } from '../fixture_data'; import { createStore, monitoringDashboard } from '~/monitoring/stores'; import { createStore as createEmbedGroupStore } from '~/monitoring/stores/embed_group'; @@ -63,7 +61,7 @@ describe('Dashboard Panel', () => { wrapper = shallowMount(DashboardPanel, { propsData: { graphData, - settingsPath: propsData.settingsPath, + settingsPath: dashboardProps.settingsPath, ...props, }, store, @@ -137,10 +135,6 @@ describe('Dashboard Panel', () => { expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true); expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true); }); - - it('does not contain a tabindex attribute', () => { - expect(wrapper.find(MonitorEmptyChart).contains('[tabindex]')).toBe(false); - }); }); describe('When graphData is null', () => { @@ -233,23 +227,32 @@ describe('Dashboard Panel', () => { expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true); }); - it.each` - data | component - ${dataWithType(panelTypes.AREA_CHART)} | ${MonitorTimeSeriesChart} - ${dataWithType(panelTypes.LINE_CHART)} | ${MonitorTimeSeriesChart} - ${anomalyMockGraphData} | ${MonitorAnomalyChart} - ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} - ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} - ${singleStatMetricsResult} | ${MonitorSingleStatChart} - ${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart} - ${barMockData} | ${MonitorBarChart} - `('wrapps a $data.type component binding attributes', ({ data, component }) => { + describe.each` + data | component | hasCtxMenu + ${dataWithType(panelTypes.AREA_CHART)} | ${MonitorTimeSeriesChart} | ${true} + ${dataWithType(panelTypes.LINE_CHART)} | ${MonitorTimeSeriesChart} | ${true} + ${singleStatGraphData()} | ${MonitorSingleStatChart} | ${true} + ${anomalyGraphData()} | ${MonitorAnomalyChart} | ${false} + ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} | ${false} + ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} | ${false} + ${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart} | ${false} + ${barMockData} | ${MonitorBarChart} | ${false} + `('when $data.type data is provided', ({ data, component, hasCtxMenu }) => { const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' }; - createWrapper({ graphData: data }, { attrs }); - expect(wrapper.find(component).exists()).toBe(true); - expect(wrapper.find(component).isVueInstance()).toBe(true); - expect(wrapper.find(component).attributes()).toMatchObject(attrs); + beforeEach(() => { + createWrapper({ graphData: data }, { attrs }); + }); + + it(`renders the chart component and binds attributes`, () => { + expect(wrapper.find(component).exists()).toBe(true); + expect(wrapper.find(component).isVueInstance()).toBe(true); + expect(wrapper.find(component).attributes()).toMatchObject(attrs); + }); + + it(`contextual menu is ${hasCtxMenu ? '' : 'not '}shown`, () => { + expect(findCtxMenu().exists()).toBe(hasCtxMenu); + }); }); }); }); @@ -307,7 +310,7 @@ describe('Dashboard Panel', () => { return wrapper.vm.$nextTick(() => { expect(findEditCustomMetricLink().text()).toBe('Edit metrics'); - expect(findEditCustomMetricLink().attributes('href')).toBe(propsData.settingsPath); + expect(findEditCustomMetricLink().attributes('href')).toBe(dashboardProps.settingsPath); }); }); }); @@ -361,7 +364,7 @@ describe('Dashboard Panel', () => { }); }); - it('it is overriden when a datazoom event is received', () => { + it('it is overridden when a datazoom event is received', () => { state.logsPath = mockLogsPath; state.timeRange = mockTimeRange; @@ -424,7 +427,7 @@ describe('Dashboard Panel', () => { wrapper = shallowMount(DashboardPanel, { propsData: { clipboardText: exampleText, - settingsPath: propsData.settingsPath, + settingsPath: dashboardProps.settingsPath, graphData: { y_label: 'metric', ...graphData, @@ -474,7 +477,7 @@ describe('Dashboard Panel', () => { wrapper = shallowMount(DashboardPanel, { propsData: { graphData, - settingsPath: propsData.settingsPath, + settingsPath: dashboardProps.settingsPath, namespace: mockNamespace, }, store, diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 7bb4c68b4cd..4b7f7a9ddb3 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -6,16 +6,18 @@ import { objectToQuery } from '~/lib/utils/url_utility'; import VueDraggable from 'vuedraggable'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import { metricStates } from '~/monitoring/constants'; +import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; import Dashboard from '~/monitoring/components/dashboard.vue'; import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; +import RefreshButton from '~/monitoring/components/refresh_button.vue'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; import EmptyState from '~/monitoring/components/empty_state.vue'; import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; +import GraphGroup from '~/monitoring/components/graph_group.vue'; import LinksSection from '~/monitoring/components/links_section.vue'; import { createStore } from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; @@ -24,12 +26,17 @@ import { setupStoreWithDashboard, setMetricResult, setupStoreWithData, - setupStoreWithVariable, + setupStoreWithDataForPanelCount, setupStoreWithLinks, } from '../store_utils'; -import { environmentData, dashboardGitResponse, propsData } from '../mock_data'; -import { metricsDashboardViewModel, metricsDashboardPanelCount } from '../fixture_data'; +import { environmentData, dashboardGitResponse, storeVariables } from '../mock_data'; +import { + metricsDashboardViewModel, + metricsDashboardPanelCount, + dashboardProps, +} from '../fixture_data'; import createFlash from '~/flash'; +import { TEST_HOST } from 'helpers/test_constants'; jest.mock('~/flash'); @@ -48,7 +55,7 @@ describe('Dashboard', () => { const createShallowWrapper = (props = {}, options = {}) => { wrapper = shallowMount(Dashboard, { - propsData: { ...propsData, ...props }, + propsData: { ...dashboardProps, ...props }, store, stubs: { DashboardHeader, @@ -59,7 +66,7 @@ describe('Dashboard', () => { const createMountedWrapper = (props = {}, options = {}) => { wrapper = mount(Dashboard, { - propsData: { ...propsData, ...props }, + propsData: { ...dashboardProps, ...props }, store, stubs: { 'graph-group': true, @@ -120,13 +127,13 @@ describe('Dashboard', () => { }); it('shows up a loading state', () => { - store.state.monitoringDashboard.emptyState = 'loading'; + store.state.monitoringDashboard.emptyState = dashboardEmptyStates.LOADING; createShallowWrapper({ hasMetrics: true }); return wrapper.vm.$nextTick().then(() => { expect(wrapper.find(EmptyState).exists()).toBe(true); - expect(wrapper.find(EmptyState).props('selectedState')).toBe('loading'); + expect(wrapper.find(EmptyState).props('selectedState')).toBe(dashboardEmptyStates.LOADING); }); }); @@ -136,7 +143,7 @@ describe('Dashboard', () => { setupStoreWithData(store); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.showEmptyState).toEqual(false); + expect(wrapper.vm.emptyState).toBeNull(); expect(wrapper.findAll('.prometheus-panel')).toHaveLength(0); }); }); @@ -157,6 +164,103 @@ describe('Dashboard', () => { }); }); + describe('panel containers layout', () => { + const findPanelLayoutWrapperAt = index => { + return wrapper + .find(GraphGroup) + .findAll('[data-testid="dashboard-panel-layout-wrapper"]') + .at(index); + }; + + beforeEach(() => { + createMountedWrapper({ hasMetrics: true }); + + return wrapper.vm.$nextTick(); + }); + + describe('when the graph group has an even number of panels', () => { + it('2 panels - all panel wrappers take half width of their parent', () => { + setupStoreWithDataForPanelCount(store, 2); + + wrapper.vm.$nextTick(() => { + expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); + }); + }); + + it('4 panels - all panel wrappers take half width of their parent', () => { + setupStoreWithDataForPanelCount(store, 4); + + wrapper.vm.$nextTick(() => { + expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(2).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(3).classes('col-lg-6')).toBe(true); + }); + }); + }); + + describe('when the graph group has an odd number of panels', () => { + it('1 panel - panel wrapper does not take half width of its parent', () => { + setupStoreWithDataForPanelCount(store, 1); + + wrapper.vm.$nextTick(() => { + expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(false); + }); + }); + + it('3 panels - all panels but last take half width of their parents', () => { + setupStoreWithDataForPanelCount(store, 3); + + wrapper.vm.$nextTick(() => { + expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(2).classes('col-lg-6')).toBe(false); + }); + }); + + it('5 panels - all panels but last take half width of their parents', () => { + setupStoreWithDataForPanelCount(store, 5); + + wrapper.vm.$nextTick(() => { + expect(findPanelLayoutWrapperAt(0).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(1).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(2).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(3).classes('col-lg-6')).toBe(true); + expect(findPanelLayoutWrapperAt(4).classes('col-lg-6')).toBe(false); + }); + }); + }); + }); + + describe('dashboard validation warning', () => { + it('displays a warning if there are validation warnings', () => { + createMountedWrapper({ hasMetrics: true }); + + store.commit( + `monitoringDashboard/${types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS}`, + true, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).toHaveBeenCalled(); + }); + }); + + it('does not display a warning if there are no validation warnings', () => { + createMountedWrapper({ hasMetrics: true }); + + store.commit( + `monitoringDashboard/${types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS}`, + false, + ); + + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).not.toHaveBeenCalled(); + }); + }); + }); + describe('when the URL contains a reference to a panel', () => { let location; @@ -323,12 +427,72 @@ describe('Dashboard', () => { ); }); }); + + describe('when custom dashboard is selected', () => { + const windowLocation = window.location; + const findDashboardDropdown = () => wrapper.find(DashboardHeader).find(DashboardsDropdown); + + beforeEach(() => { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + projectPath: TEST_HOST, + }); + + delete window.location; + window.location = { ...windowLocation, assign: jest.fn() }; + createMountedWrapper(); + + return wrapper.vm.$nextTick(); + }); + + afterEach(() => { + window.location = windowLocation; + }); + + it('encodes dashboard param', () => { + findDashboardDropdown().vm.$emit('selectDashboard', { + path: '.gitlab/dashboards/dashboard©.yml', + display_name: 'dashboard©.yml', + }); + expect(window.location.assign).toHaveBeenCalledWith( + `${TEST_HOST}/-/metrics/dashboard%26copy.yml`, + ); + }); + }); + }); + + describe('when all panels in the first group are loading', () => { + const findGroupAt = i => wrapper.findAll(GraphGroup).at(i); + + beforeEach(() => { + setupStoreWithDashboard(store); + + const { panels } = store.state.monitoringDashboard.dashboard.panelGroups[0]; + panels.forEach(({ metrics }) => { + store.commit(`monitoringDashboard/${types.REQUEST_METRIC_RESULT}`, { + metricId: metrics[0].metricId, + }); + }); + + createShallowWrapper(); + + return wrapper.vm.$nextTick(); + }); + + it('a loading icon appears in the first group', () => { + expect(findGroupAt(0).props('isLoading')).toBe(true); + }); + + it('a loading icon does not appear in the second group', () => { + expect(findGroupAt(1).props('isLoading')).toBe(false); + }); }); describe('when all requests have been commited by the store', () => { beforeEach(() => { store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { currentEnvironmentName: 'production', + currentDashboard: dashboardGitResponse[0].path, + projectPath: TEST_HOST, }); createMountedWrapper({ hasMetrics: true }); setupStoreWithData(store); @@ -341,13 +505,26 @@ describe('Dashboard', () => { findAllEnvironmentsDropdownItems().wrappers.forEach((itemWrapper, index) => { const anchorEl = itemWrapper.find('a'); - if (anchorEl.exists() && environmentData[index].metrics_path) { + if (anchorEl.exists()) { const href = anchorEl.attributes('href'); - expect(href).toBe(environmentData[index].metrics_path); + const currentDashboard = encodeURIComponent(dashboardGitResponse[0].path); + const environmentId = encodeURIComponent(environmentData[index].id); + const url = `${TEST_HOST}/-/metrics/${currentDashboard}?environment=${environmentId}`; + expect(href).toBe(url); } }); }); + it('it does not show loading icons in any group', () => { + setupStoreWithData(store); + + wrapper.vm.$nextTick(() => { + wrapper.findAll(GraphGroup).wrappers.forEach(groupWrapper => { + expect(groupWrapper.props('isLoading')).toBe(false); + }); + }); + }); + // Note: This test is not working, .active does not show the active environment // eslint-disable-next-line jest/no-disabled-tests it.skip('renders the environments dropdown with a single active element', () => { @@ -464,10 +641,9 @@ describe('Dashboard', () => { setupStoreWithData(store); return wrapper.vm.$nextTick().then(() => { - const refreshBtn = wrapper.find(DashboardHeader).findAll({ ref: 'refreshDashboardBtn' }); + const refreshBtn = wrapper.find(DashboardHeader).find(RefreshButton); - expect(refreshBtn).toHaveLength(1); - expect(refreshBtn.is(GlDeprecatedButton)).toBe(true); + expect(refreshBtn.exists()).toBe(true); }); }); @@ -475,8 +651,7 @@ describe('Dashboard', () => { beforeEach(() => { createShallowWrapper({ hasMetrics: true }); setupStoreWithData(store); - setupStoreWithVariable(store); - + store.state.monitoringDashboard.variables = storeVariables; return wrapper.vm.$nextTick(); }); @@ -1041,6 +1216,34 @@ describe('Dashboard', () => { }); }); + describe('keyboard shortcuts', () => { + const currentDashboard = dashboardGitResponse[1].path; + const panelRef = 'dashboard-panel-response-metrics-aws-elb-4-1'; // skip expanded panel + + // While the recommendation in the documentation is to test + // with a data-testid attribute, I want to make sure that + // the dashboard panels have a ref attribute set. + const getDashboardPanel = () => wrapper.find({ ref: panelRef }); + + beforeEach(() => { + setupStoreWithData(store); + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard, + }); + createShallowWrapper({ hasMetrics: true }); + + wrapper.setData({ hoveredPanel: panelRef }); + + return wrapper.vm.$nextTick(); + }); + + it('contains a ref attribute inside a DashboardPanel component', () => { + const dashboardPanel = getDashboardPanel(); + + expect(dashboardPanel.exists()).toBe(true); + }); + }); + describe('add custom metrics', () => { const findAddMetricButton = () => wrapper.find(DashboardHeader).find({ ref: 'addMetricBtn' }); @@ -1082,7 +1285,7 @@ describe('Dashboard', () => { it('uses modal for custom metrics form', () => { expect(wrapper.find(GlModal).exists()).toBe(true); - expect(wrapper.find(GlModal).attributes().modalid).toBe('add-metric'); + expect(wrapper.find(GlModal).attributes().modalid).toBe('addMetric'); }); it('adding new metric is tracked', done => { const submitButton = wrapper diff --git a/spec/frontend/monitoring/components/dashboard_template_spec.js b/spec/frontend/monitoring/components/dashboard_template_spec.js index a1a450d4abe..8941e57c4ce 100644 --- a/spec/frontend/monitoring/components/dashboard_template_spec.js +++ b/spec/frontend/monitoring/components/dashboard_template_spec.js @@ -5,7 +5,7 @@ import Dashboard from '~/monitoring/components/dashboard.vue'; import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; import { createStore } from '~/monitoring/stores'; import { setupAllDashboards } from '../store_utils'; -import { propsData } from '../mock_data'; +import { dashboardProps } from '../fixture_data'; jest.mock('~/lib/utils/url_utility'); @@ -29,7 +29,7 @@ describe('Dashboard template', () => { it('matches the default snapshot', () => { wrapper = shallowMount(Dashboard, { - propsData: { ...propsData }, + propsData: { ...dashboardProps }, store, stubs: { DashboardHeader, diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index a74c621db9b..276e20bae6a 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -9,7 +9,8 @@ import { updateHistory, } from '~/lib/utils/url_utility'; import axios from '~/lib/utils/axios_utils'; -import { mockProjectDir, propsData } from '../mock_data'; +import { mockProjectDir } from '../mock_data'; +import { dashboardProps } from '../fixture_data'; import Dashboard from '~/monitoring/components/dashboard.vue'; import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; @@ -26,7 +27,7 @@ describe('dashboard invalid url parameters', () => { const createMountedWrapper = (props = { hasMetrics: true }, options = {}) => { wrapper = mount(Dashboard, { - propsData: { ...propsData, ...props }, + propsData: { ...dashboardProps, ...props }, store, stubs: { 'graph-group': true, 'dashboard-panel': true, 'dashboard-header': DashboardHeader }, ...options, diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index b29d86cbc5b..d09fcc92ee7 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -1,14 +1,12 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem, GlModal, GlLoadingIcon, GlAlert, GlIcon } from '@gitlab/ui'; -import waitForPromises from 'helpers/wait_for_promises'; +import { GlDropdownItem, GlIcon } from '@gitlab/ui'; import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; -import DuplicateDashboardForm from '~/monitoring/components/duplicate_dashboard_form.vue'; -import { dashboardGitResponse } from '../mock_data'; +import { dashboardGitResponse, selfMonitoringDashboardGitResponse } from '../mock_data'; const defaultBranch = 'master'; - +const modalId = 'duplicateDashboardModalId'; const starredDashboards = dashboardGitResponse.filter(({ starred }) => starred); const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starred); @@ -32,6 +30,7 @@ describe('DashboardsDropdown', () => { propsData: { ...props, defaultBranch, + modalId, }, sync: false, ...storeOpts, @@ -82,7 +81,7 @@ describe('DashboardsDropdown', () => { const searchTerm = 'Default'; setSearchTerm(searchTerm); - return wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick().then(() => { expect(findItems()).toHaveLength(1); }); }); @@ -91,7 +90,7 @@ describe('DashboardsDropdown', () => { const searchTerm = 'does-not-exist'; setSearchTerm(searchTerm); - return wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick().then(() => { expect(findNoItemsMsg().isVisible()).toBe(true); }); }); @@ -151,12 +150,18 @@ describe('DashboardsDropdown', () => { }); }); - describe('when a system dashboard is selected', () => { + const duplicableCases = [ + dashboardGitResponse[0], + dashboardGitResponse[2], + selfMonitoringDashboardGitResponse[0], + ]; + + describe.each(duplicableCases)('when the selected dashboard can be duplicated', dashboard => { let duplicateDashboardAction; let modalDirective; beforeEach(() => { - [mockSelectedDashboard] = dashboardGitResponse; + mockSelectedDashboard = dashboard; modalDirective = jest.fn(); duplicateDashboardAction = jest.fn().mockResolvedValue(); @@ -172,152 +177,59 @@ describe('DashboardsDropdown', () => { }, }, ); - - wrapper.vm.$refs.duplicateDashboardModal.hide = jest.fn(); }); - it('displays an item for each dashboard plus a "duplicate dashboard" item', () => { - const item = wrapper.findAll({ ref: 'duplicateDashboardItem' }); - + it('displays a dropdown item for each dashboard', () => { expect(findItems().length).toEqual(dashboardGitResponse.length + 1); - expect(item.length).toBe(1); }); - describe('modal form', () => { - let okEvent; - - const findModal = () => wrapper.find(GlModal); - const findAlert = () => wrapper.find(GlAlert); - - beforeEach(() => { - okEvent = { - preventDefault: jest.fn(), - }; - }); - - it('exists and contains a form to duplicate a dashboard', () => { - expect(findModal().exists()).toBe(true); - expect(findModal().contains(DuplicateDashboardForm)).toBe(true); - }); - - it('saves a new dashboard', () => { - findModal().vm.$emit('ok', okEvent); - - return waitForPromises().then(() => { - expect(okEvent.preventDefault).toHaveBeenCalled(); - - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.vm.$refs.duplicateDashboardModal.hide).toHaveBeenCalled(); - expect(wrapper.emitted().selectDashboard).toBeTruthy(); - expect(findAlert().exists()).toBe(false); - }); - }); - - describe('when a new dashboard is saved succesfully', () => { - const newDashboard = { - can_edit: true, - default: false, - display_name: 'A new dashboard', - system_dashboard: false, - }; - - const submitForm = formVals => { - duplicateDashboardAction.mockResolvedValueOnce(newDashboard); - findModal() - .find(DuplicateDashboardForm) - .vm.$emit('change', { - dashboard: 'common_metrics.yml', - commitMessage: 'A commit message', - ...formVals, - }); - findModal().vm.$emit('ok', okEvent); - }; - - it('to the default branch, redirects to the new dashboard', () => { - submitForm({ - branch: defaultBranch, - }); - - return waitForPromises().then(() => { - expect(wrapper.emitted().selectDashboard[0][0]).toEqual(newDashboard); - }); - }); - - it('to a new branch refreshes in the current dashboard', () => { - submitForm({ - branch: 'another-branch', - }); - - return waitForPromises().then(() => { - expect(wrapper.emitted().selectDashboard[0][0]).toEqual(dashboardGitResponse[0]); - }); - }); - }); - - it('handles error when a new dashboard is not saved', () => { - const errMsg = 'An error occurred'; - - duplicateDashboardAction.mockRejectedValueOnce(errMsg); - findModal().vm.$emit('ok', okEvent); + it('displays one "duplicate dashboard" dropdown item with a directive attached', () => { + const item = wrapper.findAll('[data-testid="duplicateDashboardItem"]'); - return waitForPromises().then(() => { - expect(okEvent.preventDefault).toHaveBeenCalled(); + expect(item.length).toBe(1); + }); - expect(findAlert().exists()).toBe(true); - expect(findAlert().text()).toBe(errMsg); + it('"duplicate dashboard" dropdown item directive works', () => { + const item = wrapper.find('[data-testid="duplicateDashboardItem"]'); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.vm.$refs.duplicateDashboardModal.hide).not.toHaveBeenCalled(); - }); - }); + item.trigger('click'); - it('id is correct, as the value of modal directive binding matches modal id', () => { - expect(modalDirective).toHaveBeenCalledTimes(1); - - // Binding's second argument contains the modal id - expect(modalDirective.mock.calls[0][1]).toEqual( - expect.objectContaining({ - value: findModal().props('modalId'), - }), - ); + return wrapper.vm.$nextTick().then(() => { + expect(modalDirective).toHaveBeenCalled(); }); + }); - it('updates the form on changes', () => { - const formVals = { - dashboard: 'common_metrics.yml', - commitMessage: 'A commit message', - }; - - findModal() - .find(DuplicateDashboardForm) - .vm.$emit('change', formVals); + it('id is correct, as the value of modal directive binding matches modal id', () => { + expect(modalDirective).toHaveBeenCalledTimes(1); - // Binding's second argument contains the modal id - expect(wrapper.vm.form).toEqual(formVals); - }); + // Binding's second argument contains the modal id + expect(modalDirective.mock.calls[0][1]).toEqual( + expect.objectContaining({ + value: modalId, + }), + ); }); }); - describe('when a custom dashboard is selected', () => { - const findModal = () => wrapper.find(GlModal); + const nonDuplicableCases = [dashboardGitResponse[1], selfMonitoringDashboardGitResponse[1]]; - beforeEach(() => { - wrapper = createComponent({ - selectedDashboard: dashboardGitResponse[1], + describe.each(nonDuplicableCases)( + 'when the selected dashboard can not be duplicated', + dashboard => { + beforeEach(() => { + mockSelectedDashboard = dashboard; + + wrapper = createComponent(); }); - }); - it('displays an item for each dashboard', () => { - const item = wrapper.findAll({ ref: 'duplicateDashboardItem' }); + it('displays a dropdown list item for each dashboard, but no list item for "duplicate dashboard"', () => { + const item = wrapper.findAll('[data-testid="duplicateDashboardItem"]'); - expect(findItems()).toHaveLength(dashboardGitResponse.length); - expect(item.length).toBe(0); - }); - - it('modal form does not exist and contains a form to duplicate a dashboard', () => { - expect(findModal().exists()).toBe(false); - }); - }); + expect(findItems()).toHaveLength(dashboardGitResponse.length); + expect(item.length).toBe(0); + }); + }, + ); describe('when a dashboard gets selected by the user', () => { beforeEach(() => { diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js new file mode 100644 index 00000000000..d8ffb4443ac --- /dev/null +++ b/spec/frontend/monitoring/components/duplicate_dashboard_modal_spec.js @@ -0,0 +1,111 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui'; + +import waitForPromises from 'helpers/wait_for_promises'; + +import DuplicateDashboardModal from '~/monitoring/components/duplicate_dashboard_modal.vue'; +import DuplicateDashboardForm from '~/monitoring/components/duplicate_dashboard_form.vue'; + +import { dashboardGitResponse } from '../mock_data'; + +describe('duplicate dashboard modal', () => { + let wrapper; + let mockDashboards; + let mockSelectedDashboard; + let duplicateDashboardAction; + let okEvent; + + function createComponent(opts = {}) { + const storeOpts = { + methods: { + duplicateSystemDashboard: jest.fn(), + }, + computed: { + allDashboards: () => mockDashboards, + selectedDashboard: () => mockSelectedDashboard, + }, + }; + + return shallowMount(DuplicateDashboardModal, { + propsData: { + defaultBranch: 'master', + modalId: 'id', + }, + sync: false, + ...storeOpts, + ...opts, + }); + } + + const findAlert = () => wrapper.find(GlAlert); + const findModal = () => wrapper.find(GlModal); + const findDuplicateDashboardForm = () => wrapper.find(DuplicateDashboardForm); + + beforeEach(() => { + mockDashboards = dashboardGitResponse; + [mockSelectedDashboard] = dashboardGitResponse; + + duplicateDashboardAction = jest.fn().mockResolvedValue(); + + okEvent = { + preventDefault: jest.fn(), + }; + + wrapper = createComponent({ + methods: { + // Mock vuex actions + duplicateSystemDashboard: duplicateDashboardAction, + }, + }); + + wrapper.vm.$refs.duplicateDashboardModal.hide = jest.fn(); + }); + + it('contains a form to duplicate a dashboard', () => { + expect(findDuplicateDashboardForm().exists()).toBe(true); + }); + + it('saves a new dashboard', () => { + findModal().vm.$emit('ok', okEvent); + + return waitForPromises().then(() => { + expect(okEvent.preventDefault).toHaveBeenCalled(); + expect(wrapper.emitted().dashboardDuplicated).toBeTruthy(); + expect(wrapper.emitted().dashboardDuplicated[0]).toEqual([dashboardGitResponse[0]]); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.vm.$refs.duplicateDashboardModal.hide).toHaveBeenCalled(); + expect(findAlert().exists()).toBe(false); + }); + }); + + it('handles error when a new dashboard is not saved', () => { + const errMsg = 'An error occurred'; + + duplicateDashboardAction.mockRejectedValueOnce(errMsg); + findModal().vm.$emit('ok', okEvent); + + return waitForPromises().then(() => { + expect(okEvent.preventDefault).toHaveBeenCalled(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(errMsg); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.vm.$refs.duplicateDashboardModal.hide).not.toHaveBeenCalled(); + }); + }); + + it('updates the form on changes', () => { + const formVals = { + dashboard: 'common_metrics.yml', + commitMessage: 'A commit message', + }; + + findModal() + .find(DuplicateDashboardForm) + .vm.$emit('change', formVals); + + // Binding's second argument contains the modal id + expect(wrapper.vm.form).toEqual(formVals); + }); +}); diff --git a/spec/frontend/monitoring/components/empty_state_spec.js b/spec/frontend/monitoring/components/empty_state_spec.js index e985e5fb443..abb8b21e9f4 100644 --- a/spec/frontend/monitoring/components/empty_state_spec.js +++ b/spec/frontend/monitoring/components/empty_state_spec.js @@ -1,10 +1,11 @@ import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { dashboardEmptyStates } from '~/monitoring/constants'; import EmptyState from '~/monitoring/components/empty_state.vue'; function createComponent(props) { return shallowMount(EmptyState, { propsData: { - ...props, settingsPath: '/settingsPath', clustersPath: '/clustersPath', documentationPath: '/documentationPath', @@ -13,30 +14,40 @@ function createComponent(props) { emptyNoDataSvgPath: '/path/to/no-data.svg', emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg', emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', + ...props, }, }); } describe('EmptyState', () => { + it('shows loading state with a loading icon', () => { + const wrapper = createComponent({ + selectedState: dashboardEmptyStates.LOADING, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(GlEmptyState).exists()).toBe(false); + }); + it('shows gettingStarted state', () => { const wrapper = createComponent({ - selectedState: 'gettingStarted', + selectedState: dashboardEmptyStates.GETTING_STARTED, }); expect(wrapper.element).toMatchSnapshot(); }); - it('shows loading state', () => { + it('shows unableToConnect state', () => { const wrapper = createComponent({ - selectedState: 'loading', + selectedState: dashboardEmptyStates.UNABLE_TO_CONNECT, }); expect(wrapper.element).toMatchSnapshot(); }); - it('shows unableToConnect state', () => { + it('shows noData state', () => { const wrapper = createComponent({ - selectedState: 'unableToConnect', + selectedState: dashboardEmptyStates.NO_DATA, }); expect(wrapper.element).toMatchSnapshot(); diff --git a/spec/frontend/monitoring/components/graph_group_spec.js b/spec/frontend/monitoring/components/graph_group_spec.js index 92829135c0f..81f5d90c310 100644 --- a/spec/frontend/monitoring/components/graph_group_spec.js +++ b/spec/frontend/monitoring/components/graph_group_spec.js @@ -1,13 +1,14 @@ import { shallowMount } from '@vue/test-utils'; import GraphGroup from '~/monitoring/components/graph_group.vue'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; describe('Graph group component', () => { let wrapper; const findGroup = () => wrapper.find({ ref: 'graph-group' }); const findContent = () => wrapper.find({ ref: 'graph-group-content' }); - const findCaretIcon = () => wrapper.find(Icon); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findCaretIcon = () => wrapper.find(GlIcon); const findToggleButton = () => wrapper.find('[data-testid="group-toggle-button"]'); const createComponent = propsData => { @@ -28,28 +29,28 @@ describe('Graph group component', () => { }); }); + it('should not show a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + it('should show the angle-down caret icon', () => { expect(findContent().isVisible()).toBe(true); expect(findCaretIcon().props('name')).toBe('angle-down'); }); it('should show the angle-right caret icon when the user collapses the group', () => { - wrapper.vm.collapse(); + findToggleButton().trigger('click'); - return wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick().then(() => { expect(findContent().isVisible()).toBe(false); expect(findCaretIcon().props('name')).toBe('angle-right'); }); }); - it('should contain a tabindex', () => { - expect(findGroup().contains('[tabindex]')).toBe(true); - }); - it('should contain a tab index for the collapse button', () => { const groupToggle = findToggleButton(); - expect(groupToggle.contains('[tabindex]')).toBe(true); + expect(groupToggle.is('[tabindex]')).toBe(true); }); it('should show the open the group when collapseGroup is set to true', () => { @@ -57,77 +58,94 @@ describe('Graph group component', () => { collapseGroup: true, }); - return wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick().then(() => { expect(findContent().isVisible()).toBe(true); expect(findCaretIcon().props('name')).toBe('angle-down'); }); }); + }); - describe('When group is collapsed', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - collapseGroup: true, - }); + describe('When group is collapsed', () => { + beforeEach(() => { + createComponent({ + name: 'panel', + collapseGroup: true, }); + }); - it('should show the angle-down caret icon when collapseGroup is true', () => { - expect(wrapper.vm.caretIcon).toBe('angle-right'); - }); + it('should show the angle-down caret icon when collapseGroup is true', () => { + expect(findCaretIcon().props('name')).toBe('angle-right'); + }); - it('should show the angle-right caret icon when collapseGroup is false', () => { - wrapper.vm.collapse(); + it('should show the angle-right caret icon when collapseGroup is false', () => { + findToggleButton().trigger('click'); - expect(wrapper.vm.caretIcon).toBe('angle-down'); + return wrapper.vm.$nextTick().then(() => { + expect(findCaretIcon().props('name')).toBe('angle-down'); }); + }); - it('should call collapse the graph group content when enter is pressed on the caret icon', () => { - const graphGroupContent = findContent(); - const button = findToggleButton(); + it('should call collapse the graph group content when enter is pressed on the caret icon', () => { + const graphGroupContent = findContent(); + const button = findToggleButton(); - button.trigger('keyup.enter'); + button.trigger('keyup.enter'); + + expect(graphGroupContent.isVisible()).toBe(false); + }); + }); - expect(graphGroupContent.isVisible()).toBe(false); + describe('When groups can not be collapsed', () => { + beforeEach(() => { + createComponent({ + name: 'panel', + showPanels: false, + collapseGroup: false, }); }); - describe('When groups can not be collapsed', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - showPanels: false, - collapseGroup: false, - }); + it('should not have a container when showPanels is false', () => { + expect(findGroup().exists()).toBe(false); + expect(findContent().exists()).toBe(true); + }); + }); + + describe('When group is loading', () => { + beforeEach(() => { + createComponent({ + name: 'panel', + isLoading: true, }); + }); - it('should not have a container when showPanels is false', () => { - expect(findGroup().exists()).toBe(false); - expect(findContent().exists()).toBe(true); + it('should show a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('When group does not show a panel heading', () => { + beforeEach(() => { + createComponent({ + name: 'panel', + showPanels: false, + collapseGroup: false, }); }); - describe('When group does not show a panel heading', () => { - beforeEach(() => { - createComponent({ - name: 'panel', - showPanels: false, - collapseGroup: false, - }); + it('should collapse the panel content', () => { + expect(findContent().isVisible()).toBe(true); + expect(findCaretIcon().exists()).toBe(false); + }); + + it('should show the panel content when collapse is set to false', () => { + wrapper.setProps({ + collapseGroup: false, }); - it('should collapse the panel content', () => { + return wrapper.vm.$nextTick().then(() => { expect(findContent().isVisible()).toBe(true); expect(findCaretIcon().exists()).toBe(false); }); - - it('should show the panel content when clicked', () => { - wrapper.vm.collapse(); - - return wrapper.vm.$nextTick(() => { - expect(findContent().isVisible()).toBe(true); - expect(findCaretIcon().exists()).toBe(false); - }); - }); }); }); }); diff --git a/spec/frontend/monitoring/components/links_section_spec.js b/spec/frontend/monitoring/components/links_section_spec.js index 3b5b72d84ee..b771d63d51f 100644 --- a/spec/frontend/monitoring/components/links_section_spec.js +++ b/spec/frontend/monitoring/components/links_section_spec.js @@ -15,7 +15,7 @@ describe('Links Section component', () => { const setState = links => { store.state.monitoringDashboard = { ...store.state.monitoringDashboard, - showEmptyState: false, + emptyState: null, links, }; }; diff --git a/spec/frontend/monitoring/components/refresh_button_spec.js b/spec/frontend/monitoring/components/refresh_button_spec.js new file mode 100644 index 00000000000..29615638453 --- /dev/null +++ b/spec/frontend/monitoring/components/refresh_button_spec.js @@ -0,0 +1,143 @@ +import { shallowMount } from '@vue/test-utils'; +import { createStore } from '~/monitoring/stores'; +import { GlNewDropdown, GlNewDropdownItem, GlButton } from '@gitlab/ui'; + +import RefreshButton from '~/monitoring/components/refresh_button.vue'; + +describe('RefreshButton', () => { + let wrapper; + let store; + let dispatch; + let documentHidden; + + const createWrapper = () => { + wrapper = shallowMount(RefreshButton, { store }); + }; + + const findRefreshBtn = () => wrapper.find(GlButton); + const findDropdown = () => wrapper.find(GlNewDropdown); + const findOptions = () => findDropdown().findAll(GlNewDropdownItem); + const findOptionAt = index => findOptions().at(index); + + const expectFetchDataToHaveBeenCalledTimes = times => { + const refreshCalls = dispatch.mock.calls.filter(([action, payload]) => { + return action === 'monitoringDashboard/fetchDashboardData' && payload === undefined; + }); + expect(refreshCalls).toHaveLength(times); + }; + + beforeEach(() => { + store = createStore(); + jest.spyOn(store, 'dispatch').mockResolvedValue(); + dispatch = store.dispatch; + + // Document can be mock hidden by overriding the `hidden` property + documentHidden = false; + Object.defineProperty(document, 'hidden', { + configurable: true, + get() { + return documentHidden; + }, + }); + + createWrapper(); + }); + + afterEach(() => { + dispatch.mockReset(); + wrapper.destroy(); + }); + + it('refreshes data when "refresh" is clicked', () => { + findRefreshBtn().vm.$emit('click'); + expectFetchDataToHaveBeenCalledTimes(1); + }); + + it('refresh rate is "Off" in the dropdown', () => { + expect(findDropdown().props('text')).toBe('Off'); + }); + + describe('refresh rate options', () => { + it('presents multiple options', () => { + expect(findOptions().length).toBeGreaterThan(1); + }); + + it('presents an "Off" option as the default option', () => { + expect(findOptionAt(0).text()).toBe('Off'); + expect(findOptionAt(0).props('isChecked')).toBe(true); + }); + }); + + describe('when a refresh rate is chosen', () => { + const optIndex = 2; // Other option than "Off" + + beforeEach(() => { + findOptionAt(optIndex).vm.$emit('click'); + return wrapper.vm.$nextTick; + }); + + it('refresh rate appears in the dropdown', () => { + expect(findDropdown().props('text')).toBe('10s'); + }); + + it('refresh rate option is checked', () => { + expect(findOptionAt(0).props('isChecked')).toBe(false); + expect(findOptionAt(optIndex).props('isChecked')).toBe(true); + }); + + it('refreshes data when a new refresh rate is chosen', () => { + expectFetchDataToHaveBeenCalledTimes(1); + }); + + it('refreshes data after two intervals of time have passed', async () => { + jest.runOnlyPendingTimers(); + expectFetchDataToHaveBeenCalledTimes(2); + + await wrapper.vm.$nextTick(); + + jest.runOnlyPendingTimers(); + expectFetchDataToHaveBeenCalledTimes(3); + }); + + it('does not refresh data if the document is hidden', async () => { + documentHidden = true; + + jest.runOnlyPendingTimers(); + expectFetchDataToHaveBeenCalledTimes(1); + + await wrapper.vm.$nextTick(); + + jest.runOnlyPendingTimers(); + expectFetchDataToHaveBeenCalledTimes(1); + }); + + it('data is not refreshed anymore after component is destroyed', () => { + expect(jest.getTimerCount()).toBe(1); + + wrapper.destroy(); + + expect(jest.getTimerCount()).toBe(0); + }); + + describe('when "Off" refresh rate is chosen', () => { + beforeEach(() => { + findOptionAt(0).vm.$emit('click'); + return wrapper.vm.$nextTick; + }); + + it('refresh rate is "Off" in the dropdown', () => { + expect(findDropdown().props('text')).toBe('Off'); + }); + + it('refresh rate option is appears selected', () => { + expect(findOptionAt(0).props('isChecked')).toBe(true); + expect(findOptionAt(optIndex).props('isChecked')).toBe(false); + }); + + it('stops refreshing data', () => { + jest.runOnlyPendingTimers(); + expectFetchDataToHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/variables/custom_variable_spec.js b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js index 5a2b26219b6..cc384aef231 100644 --- a/spec/frontend/monitoring/components/variables/custom_variable_spec.js +++ b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js @@ -1,18 +1,25 @@ import { shallowMount } from '@vue/test-utils'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import CustomVariable from '~/monitoring/components/variables/custom_variable.vue'; +import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; describe('Custom variable component', () => { let wrapper; - const propsData = { + + const defaultProps = { name: 'env', label: 'Select environment', value: 'Production', - options: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }], + options: { + values: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }], + }, }; - const createShallowWrapper = () => { - wrapper = shallowMount(CustomVariable, { - propsData, + + const createShallowWrapper = props => { + wrapper = shallowMount(DropdownField, { + propsData: { + ...defaultProps, + ...props, + }, }); }; @@ -22,19 +29,25 @@ describe('Custom variable component', () => { it('renders dropdown element when all necessary props are passed', () => { createShallowWrapper(); - expect(findDropdown()).toExist(); + expect(findDropdown().exists()).toBe(true); }); it('renders dropdown element with a text', () => { createShallowWrapper(); - expect(findDropdown().attributes('text')).toBe(propsData.value); + expect(findDropdown().attributes('text')).toBe(defaultProps.value); }); it('renders all the dropdown items', () => { createShallowWrapper(); - expect(findDropdownItems()).toHaveLength(propsData.options.length); + expect(findDropdownItems()).toHaveLength(defaultProps.options.values.length); + }); + + it('renders dropdown when values are missing', () => { + createShallowWrapper({ options: {} }); + + expect(findDropdown().exists()).toBe(true); }); it('changing dropdown items triggers update', () => { @@ -46,7 +59,7 @@ describe('Custom variable component', () => { .vm.$emit('click'); return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'env', 'canary'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', 'canary'); }); }); }); diff --git a/spec/frontend/monitoring/components/variables/text_variable_spec.js b/spec/frontend/monitoring/components/variables/text_field_spec.js index f01584ae8bc..99c6facac38 100644 --- a/spec/frontend/monitoring/components/variables/text_variable_spec.js +++ b/spec/frontend/monitoring/components/variables/text_field_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlFormInput } from '@gitlab/ui'; -import TextVariable from '~/monitoring/components/variables/text_variable.vue'; +import TextField from '~/monitoring/components/variables/text_field.vue'; describe('Text variable component', () => { let wrapper; @@ -10,7 +10,7 @@ describe('Text variable component', () => { value: 'test-pod', }; const createShallowWrapper = () => { - wrapper = shallowMount(TextVariable, { + wrapper = shallowMount(TextField, { propsData, }); }; @@ -40,7 +40,7 @@ describe('Text variable component', () => { findInput().trigger('keyup.enter'); return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'prod-pod'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', 'prod-pod'); }); }); @@ -53,7 +53,7 @@ describe('Text variable component', () => { findInput().trigger('blur'); return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.$emit).toHaveBeenCalledWith('onUpdate', 'pod', 'canary-pod'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('input', 'canary-pod'); }); }); }); diff --git a/spec/frontend/monitoring/components/variables_section_spec.js b/spec/frontend/monitoring/components/variables_section_spec.js index fd814e81c8f..3097906ee68 100644 --- a/spec/frontend/monitoring/components/variables_section_spec.js +++ b/spec/frontend/monitoring/components/variables_section_spec.js @@ -1,13 +1,12 @@ import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import VariablesSection from '~/monitoring/components/variables_section.vue'; -import CustomVariable from '~/monitoring/components/variables/custom_variable.vue'; -import TextVariable from '~/monitoring/components/variables/text_variable.vue'; +import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; +import TextField from '~/monitoring/components/variables/text_field.vue'; import { updateHistory, mergeUrlParams } from '~/lib/utils/url_utility'; import { createStore } from '~/monitoring/stores'; import { convertVariablesForURL } from '~/monitoring/utils'; -import * as types from '~/monitoring/stores/mutation_types'; -import { mockTemplatingDataResponses } from '../mock_data'; +import { storeVariables } from '../mock_data'; jest.mock('~/lib/utils/url_utility', () => ({ updateHistory: jest.fn(), @@ -17,11 +16,6 @@ jest.mock('~/lib/utils/url_utility', () => ({ describe('Metrics dashboard/variables section component', () => { let store; let wrapper; - const sampleVariables = { - label1: mockTemplatingDataResponses.simpleText.simpleText, - label2: mockTemplatingDataResponses.advText.advText, - label3: mockTemplatingDataResponses.simpleCustom.simpleCustom, - }; const createShallowWrapper = () => { wrapper = shallowMount(VariablesSection, { @@ -29,30 +23,41 @@ describe('Metrics dashboard/variables section component', () => { }); }; - const findTextInput = () => wrapper.findAll(TextVariable); - const findCustomInput = () => wrapper.findAll(CustomVariable); + const findTextInputs = () => wrapper.findAll(TextField); + const findCustomInputs = () => wrapper.findAll(DropdownField); beforeEach(() => { store = createStore(); - store.state.monitoringDashboard.showEmptyState = false; + store.state.monitoringDashboard.emptyState = null; }); it('does not show the variables section', () => { createShallowWrapper(); - const allInputs = findTextInput().length + findCustomInput().length; + const allInputs = findTextInputs().length + findCustomInputs().length; expect(allInputs).toBe(0); }); - it('shows the variables section', () => { - createShallowWrapper(); - store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables); + describe('when variables are set', () => { + beforeEach(() => { + store.state.monitoringDashboard.variables = storeVariables; + createShallowWrapper(); + + return wrapper.vm.$nextTick; + }); + + it('shows the variables section', () => { + const allInputs = findTextInputs().length + findCustomInputs().length; + + expect(allInputs).toBe(storeVariables.length); + }); - return wrapper.vm.$nextTick(() => { - const allInputs = findTextInput().length + findCustomInput().length; + it('shows the right custom variable inputs', () => { + const customInputs = findCustomInputs(); - expect(allInputs).toBe(Object.keys(sampleVariables).length); + expect(customInputs.at(0).props('name')).toBe('customSimple'); + expect(customInputs.at(1).props('name')).toBe('customAdvanced'); }); }); @@ -65,8 +70,8 @@ describe('Metrics dashboard/variables section component', () => { monitoringDashboard: { namespaced: true, state: { - showEmptyState: false, - variables: sampleVariables, + emptyState: null, + variables: storeVariables, }, actions: { updateVariablesAndFetchData, @@ -79,14 +84,14 @@ describe('Metrics dashboard/variables section component', () => { }); it('merges the url params and refreshes the dashboard when a text-based variables inputs are updated', () => { - const firstInput = findTextInput().at(0); + const firstInput = findTextInputs().at(0); - firstInput.vm.$emit('onUpdate', 'label1', 'test'); + firstInput.vm.$emit('input', 'test'); return wrapper.vm.$nextTick(() => { expect(updateVariablesAndFetchData).toHaveBeenCalled(); expect(mergeUrlParams).toHaveBeenCalledWith( - convertVariablesForURL(sampleVariables), + convertVariablesForURL(storeVariables), window.location.href, ); expect(updateHistory).toHaveBeenCalled(); @@ -94,14 +99,14 @@ describe('Metrics dashboard/variables section component', () => { }); it('merges the url params and refreshes the dashboard when a custom-based variables inputs are updated', () => { - const firstInput = findCustomInput().at(0); + const firstInput = findCustomInputs().at(0); - firstInput.vm.$emit('onUpdate', 'label1', 'test'); + firstInput.vm.$emit('input', 'test'); return wrapper.vm.$nextTick(() => { expect(updateVariablesAndFetchData).toHaveBeenCalled(); expect(mergeUrlParams).toHaveBeenCalledWith( - convertVariablesForURL(sampleVariables), + convertVariablesForURL(storeVariables), window.location.href, ); expect(updateHistory).toHaveBeenCalled(); @@ -109,9 +114,9 @@ describe('Metrics dashboard/variables section component', () => { }); it('does not merge the url params and refreshes the dashboard if the value entered is not different that is what currently stored', () => { - const firstInput = findTextInput().at(0); + const firstInput = findTextInputs().at(0); - firstInput.vm.$emit('onUpdate', 'label1', 'Simple text'); + firstInput.vm.$emit('input', 'My default value'); expect(updateVariablesAndFetchData).not.toHaveBeenCalled(); expect(mergeUrlParams).not.toHaveBeenCalled(); diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js index b7b72a15992..97edf7bda74 100644 --- a/spec/frontend/monitoring/fixture_data.js +++ b/spec/frontend/monitoring/fixture_data.js @@ -1,5 +1,8 @@ +import { stateAndPropsFromDataset } from '~/monitoring/utils'; import { mapToDashboardViewModel } from '~/monitoring/stores/utils'; import { metricStates } from '~/monitoring/constants'; +import { convertObjectProps } from '~/lib/utils/common_utils'; +import { convertToCamelCase } from '~/lib/utils/text_utility'; import { metricsResult } from './mock_data'; @@ -7,23 +10,54 @@ import { metricsResult } from './mock_data'; export const metricsDashboardResponse = getJSONFixture( 'metrics_dashboard/environment_metrics_dashboard.json', ); + export const metricsDashboardPayload = metricsDashboardResponse.dashboard; + +const datasetState = stateAndPropsFromDataset( + // It's preferable to have props in snake_case, this will be addressed at: + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33574 + convertObjectProps( + // Some props use kebab-case, convert to snake_case first + key => convertToCamelCase(key.replace(/-/g, '_')), + metricsDashboardResponse.metrics_data, + ), +); + +// new properties like addDashboardDocumentationPath prop and alertsEndpoint +// was recently added to dashboard.vue component this needs to be +// added to fixtures data +// https://gitlab.com/gitlab-org/gitlab/-/issues/229256 +export const dashboardProps = { + ...datasetState.dataProps, + addDashboardDocumentationPath: 'https://path/to/docs', + alertsEndpoint: null, +}; + export const metricsDashboardViewModel = mapToDashboardViewModel(metricsDashboardPayload); export const metricsDashboardPanelCount = 22; export const metricResultStatus = { // First metric in fixture `metrics_dashboard/environment_metrics_dashboard.json` metricId: 'NO_DB_response_metrics_nginx_ingress_throughput_status_code', - result: metricsResult, + data: { + resultType: 'matrix', + result: metricsResult, + }, }; export const metricResultPods = { // Second metric in fixture `metrics_dashboard/environment_metrics_dashboard.json` metricId: 'NO_DB_response_metrics_nginx_ingress_latency_pod_average', - result: metricsResult, + data: { + resultType: 'matrix', + result: metricsResult, + }, }; export const metricResultEmpty = { metricId: 'NO_DB_response_metrics_nginx_ingress_16_throughput_status_code', - result: [], + data: { + resultType: 'matrix', + result: [], + }, }; // Graph data diff --git a/spec/frontend/monitoring/graph_data.js b/spec/frontend/monitoring/graph_data.js new file mode 100644 index 00000000000..e1b95723f3d --- /dev/null +++ b/spec/frontend/monitoring/graph_data.js @@ -0,0 +1,164 @@ +import { mapPanelToViewModel, normalizeQueryResponseData } from '~/monitoring/stores/utils'; +import { panelTypes, metricStates } from '~/monitoring/constants'; + +const initTime = 1435781451.781; + +const makeValue = val => [initTime, val]; +const makeValues = vals => vals.map((val, i) => [initTime + 15 * i, val]); + +// Normalized Prometheus Responses + +const scalarResult = ({ value = '1' } = {}) => + normalizeQueryResponseData({ + resultType: 'scalar', + result: makeValue(value), + }); + +const vectorResult = ({ value1 = '1', value2 = '2' } = {}) => + normalizeQueryResponseData({ + resultType: 'vector', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + value: makeValue(value1), + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9100', + }, + value: makeValue(value2), + }, + ], + }); + +const matrixSingleResult = ({ values = ['1', '2', '3'] } = {}) => + normalizeQueryResponseData({ + resultType: 'matrix', + result: [ + { + metric: {}, + values: makeValues(values), + }, + ], + }); + +const matrixMultiResult = ({ values1 = ['1', '2', '3'], values2 = ['4', '5', '6'] } = {}) => + normalizeQueryResponseData({ + resultType: 'matrix', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + values: makeValues(values1), + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9091', + }, + values: makeValues(values2), + }, + ], + }); + +// GraphData factory + +/** + * Generate mock graph data according to options + * + * @param {Object} panelOptions - Panel options as in YML. + * @param {Object} dataOptions + * @param {Object} dataOptions.metricCount + * @param {Object} dataOptions.isMultiSeries + */ +export const timeSeriesGraphData = (panelOptions = {}, dataOptions = {}) => { + const { metricCount = 1, isMultiSeries = false } = dataOptions; + + return mapPanelToViewModel({ + title: 'Time Series Panel', + type: panelTypes.LINE_CHART, + x_label: 'X Axis', + y_label: 'Y Axis', + metrics: Array.from(Array(metricCount), (_, i) => ({ + label: `Metric ${i + 1}`, + state: metricStates.OK, + result: isMultiSeries ? matrixMultiResult() : matrixSingleResult(), + })), + ...panelOptions, + }); +}; + +/** + * Generate mock graph data according to options + * + * @param {Object} panelOptions - Panel options as in YML. + * @param {Object} dataOptions + * @param {Object} dataOptions.unit + * @param {Object} dataOptions.value + * @param {Object} dataOptions.isVector + */ +export const singleStatGraphData = (panelOptions = {}, dataOptions = {}) => { + const { unit, value = '1', isVector = false } = dataOptions; + + return mapPanelToViewModel({ + title: 'Single Stat Panel', + type: panelTypes.SINGLE_STAT, + metrics: [ + { + label: 'Metric Label', + state: metricStates.OK, + result: isVector ? vectorResult({ value }) : scalarResult({ value }), + unit, + }, + ], + ...panelOptions, + }); +}; + +/** + * Generate mock graph data according to options + * + * @param {Object} panelOptions - Panel options as in YML. + * @param {Object} dataOptions + * @param {Array} dataOptions.values - Metric values + * @param {Array} dataOptions.upper - Upper boundary values + * @param {Array} dataOptions.lower - Lower boundary values + */ +export const anomalyGraphData = (panelOptions = {}, dataOptions = {}) => { + const { values, upper, lower } = dataOptions; + + return mapPanelToViewModel({ + title: 'Anomaly Panel', + type: panelTypes.ANOMALY_CHART, + x_label: 'X Axis', + y_label: 'Y Axis', + metrics: [ + { + label: `Metric`, + state: metricStates.OK, + result: matrixSingleResult({ values }), + }, + { + label: `Upper boundary`, + state: metricStates.OK, + result: matrixSingleResult({ values: upper }), + }, + { + label: `Lower boundary`, + state: metricStates.OK, + result: matrixSingleResult({ values: lower }), + }, + ], + ...panelOptions, + }); +}; diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 05b29e78ecd..49ad33402c6 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -5,28 +5,14 @@ import { TEST_HOST } from '../helpers/test_constants'; export const mockProjectDir = '/frontend-fixtures/environments-project'; export const mockApiEndpoint = `${TEST_HOST}/monitoring/mock`; -export const propsData = { - hasMetrics: false, - documentationPath: '/path/to/docs', - settingsPath: '/path/to/settings', - clustersPath: '/path/to/clusters', - tagsPath: '/path/to/tags', - defaultBranch: 'master', - emptyGettingStartedSvgPath: '/path/to/getting-started.svg', - emptyLoadingSvgPath: '/path/to/loading.svg', - emptyNoDataSvgPath: '/path/to/no-data.svg', - emptyNoDataSmallSvgPath: '/path/to/no-data-small.svg', - emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', - customMetricsAvailable: false, - customMetricsPath: '', - validateQueryPath: '', -}; +export const customDashboardBasePath = '.gitlab/dashboards'; const customDashboardsData = new Array(30).fill(null).map((_, idx) => ({ default: false, display_name: `Custom Dashboard ${idx}`, can_edit: true, system_dashboard: false, + out_of_the_box_dashboard: false, project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_${idx}.yml`, path: `.gitlab/dashboards/dashboard_${idx}.yml`, starred: false, @@ -65,136 +51,6 @@ export const anomalyDeploymentData = [ }, ]; -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: [ - { - 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]], - }, - ], - }, - ], -}; - export const deploymentData = [ { id: 111, @@ -317,6 +173,7 @@ export const dashboardGitResponse = [ display_name: 'Default', can_edit: false, system_dashboard: true, + out_of_the_box_dashboard: true, project_blob_path: null, path: 'config/prometheus/common_metrics.yml', starred: false, @@ -327,6 +184,44 @@ export const dashboardGitResponse = [ display_name: 'dashboard.yml', can_edit: true, system_dashboard: false, + out_of_the_box_dashboard: false, + project_blob_path: `${mockProjectDir}/-/blob/master/.gitlab/dashboards/dashboard.yml`, + path: '.gitlab/dashboards/dashboard.yml', + starred: true, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=.gitlab/dashboards/dashboard.yml`, + }, + { + default: false, + display_name: 'Pod Health', + can_edit: false, + system_dashboard: false, + out_of_the_box_dashboard: true, + project_blob_path: null, + path: 'config/prometheus/pod_metrics.yml', + starred: false, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/pod_metrics.yml`, + }, + ...customDashboardsData, +]; + +export const selfMonitoringDashboardGitResponse = [ + { + default: true, + display_name: 'Default', + can_edit: false, + system_dashboard: false, + out_of_the_box_dashboard: true, + project_blob_path: null, + path: 'config/prometheus/self_monitoring_default.yml', + starred: false, + user_starred_path: `${mockProjectDir}/metrics_user_starred_dashboards?dashboard_path=config/prometheus/self_monitoring_default.yml`, + }, + { + default: false, + display_name: 'dashboard.yml', + can_edit: true, + system_dashboard: false, + out_of_the_box_dashboard: false, project_blob_path: `${mockProjectDir}/-/blob/master/.gitlab/dashboards/dashboard.yml`, path: '.gitlab/dashboards/dashboard.yml', starred: true, @@ -349,30 +244,6 @@ export const metricsResult = [ }, ]; -export const singleStatMetricsResult = { - title: 'Super Chart A2', - type: 'single-stat', - weight: 2, - metrics: [ - { - id: 'metric_a1', - metricId: '2', - query: 'max(go_memstats_alloc_bytes{job="prometheus"}) by (job) /1024/1024', - unit: 'MB', - label: 'Total Consumption', - metric_id: 2, - prometheus_endpoint_path: - '/root/kubernetes-gke-project/environments/35/prometheus/api/v1/query?query=max%28go_memstats_alloc_bytes%7Bjob%3D%22prometheus%22%7D%29+by+%28job%29+%2F1024%2F1024', - result: [ - { - metric: { job: 'prometheus' }, - value: ['2019-06-26T21:03:20.881Z', 91], - }, - ], - }, - ], -}; - export const graphDataPrometheusQueryRangeMultiTrack = { title: 'Super Chart A3', type: 'heatmap', @@ -641,253 +512,186 @@ export const mockLinks = [ }, ]; -const templatingVariableTypes = { +export const templatingVariablesExamples = { text: { - simple: 'Simple text', - advanced: { - label: 'Variable 4', + textSimple: 'My default value', + textAdvanced: { + label: 'Advanced text variable', type: 'text', options: { - default_value: 'default', + default_value: 'A default value', }, }, }, custom: { - simple: ['value1', 'value2', 'value3'], - advanced: { - normal: { - label: 'Advanced Var', - type: 'custom', - options: { - values: [ - { value: 'value1', text: 'Var 1 Option 1' }, - { - value: 'value2', - text: 'Var 1 Option 2', - default: true, - }, - ], - }, - }, - withoutOpts: { - type: 'custom', - options: {}, + customSimple: ['value1', 'value2', 'value3'], + customAdvanced: { + label: 'Advanced Var', + type: 'custom', + options: { + values: [ + { value: 'value1', text: 'Var 1 Option 1' }, + { + value: 'value2', + text: 'Var 1 Option 2', + default: true, + }, + ], }, - withoutLabel: { - type: 'custom', - options: { - values: [ - { value: 'value1', text: 'Var 1 Option 1' }, - { - value: 'value2', - text: 'Var 1 Option 2', - default: true, - }, - ], - }, + }, + customAdvancedWithoutOpts: { + type: 'custom', + options: {}, + }, + customAdvancedWithoutLabel: { + type: 'custom', + options: { + values: [ + { value: 'value1', text: 'Var 1 Option 1' }, + { + value: 'value2', + text: 'Var 1 Option 2', + default: true, + }, + ], }, - withoutType: { - label: 'Variable 2', - options: { - values: [ - { value: 'value1', text: 'Var 1 Option 1' }, - { - value: 'value2', - text: 'Var 1 Option 2', - default: true, - }, - ], - }, + }, + customAdvancedWithoutType: { + label: 'Variable 2', + options: { + values: [ + { value: 'value1', text: 'Var 1 Option 1' }, + { + value: 'value2', + text: 'Var 1 Option 2', + default: true, + }, + ], }, - withoutOptText: { - label: 'Options without text', - type: 'custom', - options: { - values: [ - { value: 'value1' }, - { - value: 'value2', - default: true, - }, - ], - }, + }, + customAdvancedWithoutOptText: { + label: 'Options without text', + type: 'custom', + options: { + values: [ + { value: 'value1' }, + { + value: 'value2', + default: true, + }, + ], }, }, }, -}; - -const generateMockTemplatingData = data => { - const vars = data - ? { - variables: { - ...data, - }, - } - : {}; - return { - dashboard: { - templating: vars, + metricLabelValues: { + metricLabelValuesSimple: { + label: 'Metric Label Values', + type: 'metric_label_values', + options: { + prometheus_endpoint_path: '/series', + series_selector: 'backend:haproxy_backend_availability:ratio{env="{{env}}"}', + label: 'backend', + }, }, - }; + }, }; -const responseForSimpleTextVariable = { - simpleText: { - label: 'simpleText', +export const storeTextVariables = [ + { type: 'text', - value: 'Simple text', + name: 'textSimple', + label: 'textSimple', + value: 'My default value', }, -}; - -const responseForAdvTextVariable = { - advText: { - label: 'Variable 4', + { type: 'text', - value: 'default', + name: 'textAdvanced', + label: 'Advanced text variable', + value: 'A default value', }, -}; +]; -const responseForSimpleCustomVariable = { - simpleCustom: { - label: 'simpleCustom', +export const storeCustomVariables = [ + { + type: 'custom', + name: 'customSimple', + label: 'customSimple', + options: { + values: [ + { default: false, text: 'value1', value: 'value1' }, + { default: false, text: 'value2', value: 'value2' }, + { default: false, text: 'value3', value: 'value3' }, + ], + }, value: 'value1', - options: [ - { - default: false, - text: 'value1', - value: 'value1', - }, - { - default: false, - text: 'value2', - value: 'value2', - }, - { - default: false, - text: 'value3', - value: 'value3', - }, - ], + }, + { type: 'custom', + name: 'customAdvanced', + label: 'Advanced Var', + options: { + values: [ + { default: false, text: 'Var 1 Option 1', value: 'value1' }, + { default: true, text: 'Var 1 Option 2', value: 'value2' }, + ], + }, + value: 'value2', }, -}; - -const responseForAdvancedCustomVariableWithoutOptions = { - advCustomWithoutOpts: { - label: 'advCustomWithoutOpts', - options: [], + { type: 'custom', + name: 'customAdvancedWithoutOpts', + label: 'customAdvancedWithoutOpts', + options: { values: [] }, + value: null, }, -}; - -const responseForAdvancedCustomVariableWithoutLabel = { - advCustomWithoutLabel: { - label: 'advCustomWithoutLabel', - value: 'value2', - options: [ - { - default: false, - text: 'Var 1 Option 1', - value: 'value1', - }, - { - default: true, - text: 'Var 1 Option 2', - value: 'value2', - }, - ], + { type: 'custom', + name: 'customAdvancedWithoutLabel', + label: 'customAdvancedWithoutLabel', + value: 'value2', + options: { + values: [ + { default: false, text: 'Var 1 Option 1', value: 'value1' }, + { default: true, text: 'Var 1 Option 2', value: 'value2' }, + ], + }, }, -}; - -const responseForAdvancedCustomVariableWithoutOptText = { - advCustomWithoutOptText: { + { + type: 'custom', + name: 'customAdvancedWithoutOptText', label: 'Options without text', + options: { + values: [ + { default: false, text: 'value1', value: 'value1' }, + { default: true, text: 'value2', value: 'value2' }, + ], + }, value: 'value2', - options: [ - { - default: false, - text: 'value1', - value: 'value1', - }, - { - default: true, - text: 'value2', - value: 'value2', - }, - ], - type: 'custom', }, -}; +]; -const responseForAdvancedCustomVariable = { - ...responseForSimpleCustomVariable, - advCustomNormal: { - label: 'Advanced Var', - value: 'value2', - options: [ - { - default: false, - text: 'Var 1 Option 1', - value: 'value1', - }, - { - default: true, - text: 'Var 1 Option 2', - value: 'value2', - }, - ], - type: 'custom', +export const storeMetricLabelValuesVariables = [ + { + type: 'metric_label_values', + name: 'metricLabelValuesSimple', + label: 'Metric Label Values', + options: { prometheusEndpointPath: '/series', label: 'backend', values: [] }, + value: null, }, -}; - -const responsesForAllVariableTypes = { - ...responseForSimpleTextVariable, - ...responseForAdvTextVariable, - ...responseForSimpleCustomVariable, - ...responseForAdvancedCustomVariable, -}; +]; -export const mockTemplatingData = { - emptyTemplatingProp: generateMockTemplatingData(), - emptyVariablesProp: generateMockTemplatingData({}), - simpleText: generateMockTemplatingData({ simpleText: templatingVariableTypes.text.simple }), - advText: generateMockTemplatingData({ advText: templatingVariableTypes.text.advanced }), - simpleCustom: generateMockTemplatingData({ simpleCustom: templatingVariableTypes.custom.simple }), - advCustomWithoutOpts: generateMockTemplatingData({ - advCustomWithoutOpts: templatingVariableTypes.custom.advanced.withoutOpts, - }), - advCustomWithoutType: generateMockTemplatingData({ - advCustomWithoutType: templatingVariableTypes.custom.advanced.withoutType, - }), - advCustomWithoutLabel: generateMockTemplatingData({ - advCustomWithoutLabel: templatingVariableTypes.custom.advanced.withoutLabel, - }), - advCustomWithoutOptText: generateMockTemplatingData({ - advCustomWithoutOptText: templatingVariableTypes.custom.advanced.withoutOptText, - }), - simpleAndAdv: generateMockTemplatingData({ - simpleCustom: templatingVariableTypes.custom.simple, - advCustomNormal: templatingVariableTypes.custom.advanced.normal, - }), - allVariableTypes: generateMockTemplatingData({ - simpleText: templatingVariableTypes.text.simple, - advText: templatingVariableTypes.text.advanced, - simpleCustom: templatingVariableTypes.custom.simple, - advCustomNormal: templatingVariableTypes.custom.advanced.normal, - }), -}; +export const storeVariables = [ + ...storeTextVariables, + ...storeCustomVariables, + ...storeMetricLabelValuesVariables, +]; -export const mockTemplatingDataResponses = { - emptyTemplatingProp: {}, - emptyVariablesProp: {}, - simpleText: responseForSimpleTextVariable, - advText: responseForAdvTextVariable, - simpleCustom: responseForSimpleCustomVariable, - advCustomWithoutOpts: responseForAdvancedCustomVariableWithoutOptions, - advCustomWithoutType: {}, - advCustomWithoutLabel: responseForAdvancedCustomVariableWithoutLabel, - advCustomWithoutOptText: responseForAdvancedCustomVariableWithoutOptText, - simpleAndAdv: responseForAdvancedCustomVariable, - allVariableTypes: responsesForAllVariableTypes, +export const dashboardHeaderProps = { + defaultBranch: 'master', + addDashboardDocumentationPath: 'https://path/to/docs', + isRearrangingPanels: false, + selectedTimeRange: { + start: '2020-01-01T00:00:00.000Z', + end: '2020-01-01T01:00:00.000Z', + }, }; diff --git a/spec/frontend/monitoring/pages/dashboard_page_spec.js b/spec/frontend/monitoring/pages/dashboard_page_spec.js index e3c56ef4cbf..675165e9e56 100644 --- a/spec/frontend/monitoring/pages/dashboard_page_spec.js +++ b/spec/frontend/monitoring/pages/dashboard_page_spec.js @@ -1,21 +1,42 @@ import { shallowMount } from '@vue/test-utils'; +import { createStore } from '~/monitoring/stores'; import DashboardPage from '~/monitoring/pages/dashboard_page.vue'; import Dashboard from '~/monitoring/components/dashboard.vue'; -import { propsData } from '../mock_data'; +import { dashboardProps } from '../fixture_data'; describe('monitoring/pages/dashboard_page', () => { let wrapper; + let store; + let $route; + + const buildRouter = () => { + const dashboard = {}; + $route = { + params: { dashboard }, + query: { dashboard }, + }; + }; const buildWrapper = (props = {}) => { wrapper = shallowMount(DashboardPage, { + store, propsData: { ...props, }, + mocks: { + $route, + }, }); }; const findDashboardComponent = () => wrapper.find(Dashboard); + beforeEach(() => { + buildRouter(); + store = createStore(); + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + afterEach(() => { if (wrapper) { wrapper.destroy(); @@ -28,9 +49,18 @@ describe('monitoring/pages/dashboard_page', () => { }); it('renders the dashboard page with dashboard component', () => { - buildWrapper({ dashboardProps: propsData }); + buildWrapper({ dashboardProps }); + + const allProps = { + ...dashboardProps, + // default props values + rearrangePanelsAvailable: false, + showHeader: true, + showPanels: true, + smallEmptyState: false, + }; - expect(findDashboardComponent().props()).toMatchObject(propsData); expect(findDashboardComponent()).toExist(); + expect(allProps).toMatchObject(findDashboardComponent().props()); }); }); diff --git a/spec/frontend/monitoring/router_spec.js b/spec/frontend/monitoring/router_spec.js new file mode 100644 index 00000000000..5b8f4b3c83e --- /dev/null +++ b/spec/frontend/monitoring/router_spec.js @@ -0,0 +1,81 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import VueRouter from 'vue-router'; +import DashboardPage from '~/monitoring/pages/dashboard_page.vue'; +import Dashboard from '~/monitoring/components/dashboard.vue'; +import { createStore } from '~/monitoring/stores'; +import createRouter from '~/monitoring/router'; +import { dashboardProps } from './fixture_data'; +import { dashboardHeaderProps } from './mock_data'; + +describe('Monitoring router', () => { + let router; + let store; + const propsData = { dashboardProps: { ...dashboardProps, ...dashboardHeaderProps } }; + const NEW_BASE_PATH = '/project/my-group/test-project/-/metrics'; + const OLD_BASE_PATH = '/project/my-group/test-project/-/environments/71146/metrics'; + + const createWrapper = (basePath, routeArg) => { + const localVue = createLocalVue(); + localVue.use(VueRouter); + + router = createRouter(basePath); + if (routeArg !== undefined) { + router.push(routeArg); + } + + return mount(DashboardPage, { + localVue, + store, + router, + propsData, + }); + }; + + beforeEach(() => { + store = createStore(); + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + + afterEach(() => { + window.location.hash = ''; + }); + + describe('support old URL with full dashboard path', () => { + it.each` + route | currentDashboard + ${'/dashboard.yml'} | ${'dashboard.yml'} + ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'} + ${'/?dashboard=dashboard.yml'} | ${'dashboard.yml'} + `('sets component as $componentName for path "$route"', ({ route, currentDashboard }) => { + const wrapper = createWrapper(OLD_BASE_PATH, route); + + expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', { + currentDashboard, + }); + + expect(wrapper.find(Dashboard)).toExist(); + }); + }); + + describe('supports new URL with short dashboard path', () => { + it.each` + route | currentDashboard + ${'/'} | ${null} + ${'/dashboard.yml'} | ${'dashboard.yml'} + ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'} + ${'/folder1%2Fdashboard.yml'} | ${'folder1/dashboard.yml'} + ${'/dashboard.yml'} | ${'dashboard.yml'} + ${'/config/prometheus/common_metrics.yml'} | ${'config/prometheus/common_metrics.yml'} + ${'/config/prometheus/pod_metrics.yml'} | ${'config/prometheus/pod_metrics.yml'} + ${'/config%2Fprometheus%2Fpod_metrics.yml'} | ${'config/prometheus/pod_metrics.yml'} + `('sets component as $componentName for path "$route"', ({ route, currentDashboard }) => { + const wrapper = createWrapper(NEW_BASE_PATH, route); + + expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', { + currentDashboard, + }); + + expect(wrapper.find(Dashboard)).toExist(); + }); + }); +}); diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index d0290386f12..22f2b2e3c77 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -6,27 +6,30 @@ import statusCodes from '~/lib/utils/http_status'; import * as commonUtils from '~/lib/utils/common_utils'; import createFlash from '~/flash'; import { defaultTimeRange } from '~/vue_shared/constants'; +import * as getters from '~/monitoring/stores/getters'; import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; import { createStore } from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; import { + setGettingStartedEmptyState, + setInitialState, + setExpandedPanel, + clearExpandedPanel, + filterEnvironments, fetchData, fetchDashboard, receiveMetricsDashboardSuccess, + fetchDashboardData, + fetchPrometheusMetric, fetchDeploymentsData, fetchEnvironmentsData, - fetchDashboardData, fetchAnnotations, + fetchDashboardValidationWarnings, toggleStarredValue, - fetchPrometheusMetric, - setInitialState, - filterEnvironments, - setExpandedPanel, - clearExpandedPanel, - setGettingStartedEmptyState, duplicateSystemDashboard, updateVariablesAndFetchData, + fetchVariableMetricLabelValues, } from '~/monitoring/stores/actions'; import { gqClient, @@ -35,12 +38,12 @@ import { } from '~/monitoring/stores/utils'; import getEnvironments from '~/monitoring/queries/getEnvironments.query.graphql'; import getAnnotations from '~/monitoring/queries/getAnnotations.query.graphql'; +import getDashboardValidationWarnings from '~/monitoring/queries/getDashboardValidationWarnings.query.graphql'; import storeState from '~/monitoring/stores/state'; import { deploymentData, environmentData, annotationsData, - mockTemplatingData, dashboardGitResponse, mockDashboardsErrorResponse, } from '../mock_data'; @@ -59,11 +62,17 @@ describe('Monitoring store actions', () => { let store; let state; + let dispatch; + let commit; + beforeEach(() => { - store = createStore(); + store = createStore({ getters }); state = store.state.monitoringDashboard; mock = new MockAdapter(axios); + commit = jest.fn(); + dispatch = jest.fn(); + jest.spyOn(commonUtils, 'backOff').mockImplementation(callback => { const q = new Promise((resolve, reject) => { const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); @@ -78,6 +87,7 @@ describe('Monitoring store actions', () => { return q; }); }); + afterEach(() => { mock.reset(); @@ -85,377 +95,122 @@ describe('Monitoring store actions', () => { createFlash.mockReset(); }); - describe('fetchData', () => { - it('dispatches fetchEnvironmentsData and fetchEnvironmentsData', () => { - return testAction( - fetchData, - null, - state, - [], - [ - { type: 'fetchEnvironmentsData' }, - { type: 'fetchDashboard' }, - { type: 'fetchAnnotations' }, - ], - ); - }); + // Setup - it('dispatches when feature metricsDashboardAnnotations is on', () => { - const origGon = window.gon; - window.gon = { features: { metricsDashboardAnnotations: true } }; - - return testAction( - fetchData, + describe('setGettingStartedEmptyState', () => { + it('should commit SET_GETTING_STARTED_EMPTY_STATE mutation', done => { + testAction( + setGettingStartedEmptyState, null, state, - [], [ - { type: 'fetchEnvironmentsData' }, - { type: 'fetchDashboard' }, - { type: 'fetchAnnotations' }, + { + type: types.SET_GETTING_STARTED_EMPTY_STATE, + }, ], - ).then(() => { - window.gon = origGon; - }); - }); - }); - - describe('fetchDeploymentsData', () => { - it('dispatches receiveDeploymentsDataSuccess on success', () => { - state.deploymentsEndpoint = '/success'; - mock.onGet(state.deploymentsEndpoint).reply(200, { - deployments: deploymentData, - }); - - return testAction( - fetchDeploymentsData, - null, - state, - [], - [{ type: 'receiveDeploymentsDataSuccess', payload: deploymentData }], - ); - }); - it('dispatches receiveDeploymentsDataFailure on error', () => { - state.deploymentsEndpoint = '/error'; - mock.onGet(state.deploymentsEndpoint).reply(500); - - return testAction( - fetchDeploymentsData, - null, - state, [], - [{ type: 'receiveDeploymentsDataFailure' }], - () => { - expect(createFlash).toHaveBeenCalled(); - }, + done, ); }); }); - describe('fetchEnvironmentsData', () => { - beforeEach(() => { - state.projectPath = 'gitlab-org/gitlab-test'; - }); - - it('setting SET_ENVIRONMENTS_FILTER should dispatch fetchEnvironmentsData', () => { - jest.spyOn(gqClient, 'mutate').mockReturnValue({ - data: { - project: { - data: { - environments: [], - }, - }, + describe('setInitialState', () => { + it('should commit SET_INITIAL_STATE mutation', done => { + testAction( + setInitialState, + { + currentDashboard: '.gitlab/dashboards/dashboard.yml', + deploymentsEndpoint: 'deployments.json', }, - }); - - return testAction( - filterEnvironments, - {}, state, [ { - type: 'SET_ENVIRONMENTS_FILTER', - payload: {}, - }, - ], - [ - { - type: 'fetchEnvironmentsData', + type: types.SET_INITIAL_STATE, + payload: { + currentDashboard: '.gitlab/dashboards/dashboard.yml', + deploymentsEndpoint: 'deployments.json', + }, }, ], - ); - }); - - it('fetch environments data call takes in search param', () => { - const mockMutate = jest.spyOn(gqClient, 'mutate'); - const searchTerm = 'Something'; - const mutationVariables = { - mutation: getEnvironments, - variables: { - projectPath: state.projectPath, - search: searchTerm, - states: [ENVIRONMENT_AVAILABLE_STATE], - }, - }; - state.environmentsSearchTerm = searchTerm; - mockMutate.mockResolvedValue({}); - - return testAction( - fetchEnvironmentsData, - null, - state, [], - [ - { type: 'requestEnvironmentsData' }, - { type: 'receiveEnvironmentsDataSuccess', payload: [] }, - ], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, + done, ); }); + }); - it('dispatches receiveEnvironmentsDataSuccess on success', () => { - jest.spyOn(gqClient, 'mutate').mockResolvedValue({ - data: { - project: { - data: { - environments: environmentData, - }, - }, - }, - }); + describe('setExpandedPanel', () => { + it('Sets a panel as expanded', () => { + const group = 'group_1'; + const panel = { title: 'A Panel' }; return testAction( - fetchEnvironmentsData, - null, + setExpandedPanel, + { group, panel }, state, + [{ type: types.SET_EXPANDED_PANEL, payload: { group, panel } }], [], - [ - { type: 'requestEnvironmentsData' }, - { - type: 'receiveEnvironmentsDataSuccess', - payload: parseEnvironmentsResponse(environmentData, state.projectPath), - }, - ], ); }); + }); - it('dispatches receiveEnvironmentsDataFailure on error', () => { - jest.spyOn(gqClient, 'mutate').mockRejectedValue({}); - + describe('clearExpandedPanel', () => { + it('Clears a panel as expanded', () => { return testAction( - fetchEnvironmentsData, - null, + clearExpandedPanel, + undefined, state, + [{ type: types.SET_EXPANDED_PANEL, payload: { group: null, panel: null } }], [], - [{ type: 'requestEnvironmentsData' }, { type: 'receiveEnvironmentsDataFailure' }], ); }); }); - describe('fetchAnnotations', () => { - beforeEach(() => { - state.timeRange = { - start: '2020-04-15T12:54:32.137Z', - end: '2020-08-15T12:54:32.137Z', - }; - state.projectPath = 'gitlab-org/gitlab-test'; - state.currentEnvironmentName = 'production'; - state.currentDashboard = '.gitlab/dashboards/custom_dashboard.yml'; - }); - - it('fetches annotations data and dispatches receiveAnnotationsSuccess', () => { - const mockMutate = jest.spyOn(gqClient, 'mutate'); - const mutationVariables = { - mutation: getAnnotations, - variables: { - projectPath: state.projectPath, - environmentName: state.currentEnvironmentName, - dashboardPath: state.currentDashboard, - startingFrom: state.timeRange.start, - }, - }; - const parsedResponse = parseAnnotationsResponse(annotationsData); - - mockMutate.mockResolvedValue({ - data: { - project: { - environments: { - nodes: [ - { - metricsDashboard: { - annotations: { - nodes: parsedResponse, - }, - }, - }, - ], - }, - }, - }, - }); + // All Data + describe('fetchData', () => { + it('dispatches fetchEnvironmentsData and fetchEnvironmentsData', () => { return testAction( - fetchAnnotations, + fetchData, null, state, [], - [{ type: 'receiveAnnotationsSuccess', payload: parsedResponse }], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, + [ + { type: 'fetchEnvironmentsData' }, + { type: 'fetchDashboard' }, + { type: 'fetchAnnotations' }, + ], ); }); - it('dispatches receiveAnnotationsFailure if the annotations API call fails', () => { - const mockMutate = jest.spyOn(gqClient, 'mutate'); - const mutationVariables = { - mutation: getAnnotations, - variables: { - projectPath: state.projectPath, - environmentName: state.currentEnvironmentName, - dashboardPath: state.currentDashboard, - startingFrom: state.timeRange.start, - }, - }; - - mockMutate.mockRejectedValue({}); + it('dispatches when feature metricsDashboardAnnotations is on', () => { + const origGon = window.gon; + window.gon = { features: { metricsDashboardAnnotations: true } }; return testAction( - fetchAnnotations, + fetchData, null, state, [], - [{ type: 'receiveAnnotationsFailure' }], - () => { - expect(mockMutate).toHaveBeenCalledWith(mutationVariables); - }, - ); - }); - }); - - describe('Toggles starred value of current dashboard', () => { - let unstarredDashboard; - let starredDashboard; - - beforeEach(() => { - state.isUpdatingStarredValue = false; - [unstarredDashboard, starredDashboard] = dashboardGitResponse; - }); - - describe('toggleStarredValue', () => { - it('performs no changes if no dashboard is selected', () => { - return testAction(toggleStarredValue, null, state, [], []); - }); - - it('performs no changes if already changing starred value', () => { - state.selectedDashboard = unstarredDashboard; - state.isUpdatingStarredValue = true; - return testAction(toggleStarredValue, null, state, [], []); - }); - - it('stars dashboard if it is not starred', () => { - state.selectedDashboard = unstarredDashboard; - mock.onPost(unstarredDashboard.user_starred_path).reply(200); - - return testAction(toggleStarredValue, null, state, [ - { type: types.REQUEST_DASHBOARD_STARRING }, - { - type: types.RECEIVE_DASHBOARD_STARRING_SUCCESS, - payload: { - newStarredValue: true, - selectedDashboard: unstarredDashboard, - }, - }, - ]); - }); - - it('unstars dashboard if it is starred', () => { - state.selectedDashboard = starredDashboard; - mock.onPost(starredDashboard.user_starred_path).reply(200); - - return testAction(toggleStarredValue, null, state, [ - { type: types.REQUEST_DASHBOARD_STARRING }, - { type: types.RECEIVE_DASHBOARD_STARRING_FAILURE }, - ]); - }); - }); - }); - - describe('Set initial state', () => { - it('should commit SET_INITIAL_STATE mutation', done => { - testAction( - setInitialState, - { - currentDashboard: '.gitlab/dashboards/dashboard.yml', - deploymentsEndpoint: 'deployments.json', - }, - state, - [ - { - type: types.SET_INITIAL_STATE, - payload: { - currentDashboard: '.gitlab/dashboards/dashboard.yml', - deploymentsEndpoint: 'deployments.json', - }, - }, - ], - [], - done, - ); - }); - }); - describe('Set empty states', () => { - it('should commit SET_METRICS_ENDPOINT mutation', done => { - testAction( - setGettingStartedEmptyState, - null, - state, [ - { - type: types.SET_GETTING_STARTED_EMPTY_STATE, - }, + { type: 'fetchEnvironmentsData' }, + { type: 'fetchDashboard' }, + { type: 'fetchAnnotations' }, ], - [], - done, - ); + ).then(() => { + window.gon = origGon; + }); }); }); - describe('updateVariablesAndFetchData', () => { - it('should commit UPDATE_VARIABLES mutation and fetch data', done => { - testAction( - updateVariablesAndFetchData, - { pod: 'POD' }, - state, - [ - { - type: types.UPDATE_VARIABLES, - payload: { pod: 'POD' }, - }, - ], - [ - { - type: 'fetchDashboardData', - }, - ], - done, - ); - }); - }); + // Metrics dashboard describe('fetchDashboard', () => { - let dispatch; - let commit; const response = metricsDashboardResponse; beforeEach(() => { - dispatch = jest.fn(); - commit = jest.fn(); state.dashboardEndpoint = '/dashboard'; }); - it('on success, dispatches receive and success actions', () => { + it('on success, dispatches receive and success actions, then fetches dashboard warnings', () => { document.body.dataset.page = 'projects:environments:metrics'; mock.onGet(state.dashboardEndpoint).reply(200, response); @@ -470,6 +225,7 @@ describe('Monitoring store actions', () => { type: 'receiveMetricsDashboardSuccess', payload: { response }, }, + { type: 'fetchDashboardValidationWarnings' }, ], ); }); @@ -478,9 +234,12 @@ describe('Monitoring store actions', () => { let result; beforeEach(() => { const params = {}; + const localGetters = { + fullDashboardPath: store.getters['monitoringDashboard/fullDashboardPath'], + }; result = () => { mock.onGet(state.dashboardEndpoint).replyOnce(500, mockDashboardsErrorResponse); - return fetchDashboard({ state, commit, dispatch }, params); + return fetchDashboard({ state, commit, dispatch, getters: localGetters }, params); }; }); @@ -532,15 +291,8 @@ describe('Monitoring store actions', () => { }); }); }); - describe('receiveMetricsDashboardSuccess', () => { - let commit; - let dispatch; - - beforeEach(() => { - commit = jest.fn(); - dispatch = jest.fn(); - }); + describe('receiveMetricsDashboardSuccess', () => { it('stores groups', () => { const response = metricsDashboardResponse; receiveMetricsDashboardSuccess({ state, commit, dispatch }, { response }); @@ -552,32 +304,6 @@ describe('Monitoring store actions', () => { expect(dispatch).toHaveBeenCalledWith('fetchDashboardData'); }); - it('stores templating variables', () => { - const response = { - ...metricsDashboardResponse.dashboard, - ...mockTemplatingData.allVariableTypes.dashboard, - }; - - receiveMetricsDashboardSuccess( - { state, commit, dispatch }, - { - response: { - ...metricsDashboardResponse, - dashboard: { - ...metricsDashboardResponse.dashboard, - ...mockTemplatingData.allVariableTypes.dashboard, - }, - }, - }, - ); - - expect(commit).toHaveBeenCalledWith( - types.RECEIVE_METRICS_DASHBOARD_SUCCESS, - - response, - ); - }); - it('sets the dashboards loaded from the repository', () => { const params = {}; const response = metricsDashboardResponse; @@ -596,23 +322,21 @@ describe('Monitoring store actions', () => { expect(commit).toHaveBeenCalledWith(types.SET_ALL_DASHBOARDS, dashboardGitResponse); }); }); - describe('fetchDashboardData', () => { - let commit; - let dispatch; + // Metrics + + describe('fetchDashboardData', () => { beforeEach(() => { jest.spyOn(Tracking, 'event'); - commit = jest.fn(); - dispatch = jest.fn(); state.timeRange = defaultTimeRange; }); it('commits empty state when state.groups is empty', done => { - const getters = { + const localGetters = { metricsWithData: () => [], }; - fetchDashboardData({ state, commit, dispatch, getters }) + fetchDashboardData({ state, commit, dispatch, getters: localGetters }) .then(() => { expect(Tracking.event).toHaveBeenCalledWith( document.body.dataset.page, @@ -623,25 +347,33 @@ describe('Monitoring store actions', () => { value: 0, }, ); - expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); + expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { + defaultQueryParams: { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }, + }); expect(createFlash).not.toHaveBeenCalled(); done(); }) .catch(done.fail); }); + it('dispatches fetchPrometheusMetric for each panel query', done => { state.dashboard.panelGroups = convertObjectPropsToCamelCase( metricsDashboardResponse.dashboard.panel_groups, ); const [metric] = state.dashboard.panelGroups[0].panels[0].metrics; - const getters = { + const localGetters = { metricsWithData: () => [metric.id], }; - fetchDashboardData({ state, commit, dispatch, getters }) + fetchDashboardData({ state, commit, dispatch, getters: localGetters }) .then(() => { expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { metric, @@ -673,21 +405,27 @@ describe('Monitoring store actions', () => { const metric = state.dashboard.panelGroups[0].panels[0].metrics[0]; dispatch.mockResolvedValueOnce(); // fetchDeploymentsData + dispatch.mockResolvedValueOnce(); // fetchVariableMetricLabelValues // Mock having one out of four metrics failing dispatch.mockRejectedValueOnce(new Error('Error fetching this metric')); dispatch.mockResolvedValue(); fetchDashboardData({ state, commit, dispatch }) .then(() => { - expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 1); // plus 1 for deployments + const defaultQueryParams = { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }; + + expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); + expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { + defaultQueryParams, + }); expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { metric, - defaultQueryParams: { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }, + defaultQueryParams, }); expect(createFlash).toHaveBeenCalledTimes(1); @@ -698,6 +436,7 @@ describe('Monitoring store actions', () => { done(); }); }); + describe('fetchPrometheusMetric', () => { const defaultQueryParams = { start_time: '2019-08-06T12:40:02.184Z', @@ -738,7 +477,7 @@ describe('Monitoring store actions', () => { type: types.RECEIVE_METRIC_RESULT_SUCCESS, payload: { metricId: metric.metricId, - result: data.result, + data, }, }, ], @@ -775,7 +514,7 @@ describe('Monitoring store actions', () => { type: types.RECEIVE_METRIC_RESULT_SUCCESS, payload: { metricId: metric.metricId, - result: data.result, + data, }, }, ], @@ -817,7 +556,7 @@ describe('Monitoring store actions', () => { type: types.RECEIVE_METRIC_RESULT_SUCCESS, payload: { metricId: metric.metricId, - result: data.result, + data, }, }, ], @@ -852,7 +591,7 @@ describe('Monitoring store actions', () => { type: types.RECEIVE_METRIC_RESULT_SUCCESS, payload: { metricId: metric.metricId, - result: data.result, + data, }, }, ], @@ -901,6 +640,402 @@ describe('Monitoring store actions', () => { }); }); + // Deployments + + describe('fetchDeploymentsData', () => { + it('dispatches receiveDeploymentsDataSuccess on success', () => { + state.deploymentsEndpoint = '/success'; + mock.onGet(state.deploymentsEndpoint).reply(200, { + deployments: deploymentData, + }); + + return testAction( + fetchDeploymentsData, + null, + state, + [], + [{ type: 'receiveDeploymentsDataSuccess', payload: deploymentData }], + ); + }); + it('dispatches receiveDeploymentsDataFailure on error', () => { + state.deploymentsEndpoint = '/error'; + mock.onGet(state.deploymentsEndpoint).reply(500); + + return testAction( + fetchDeploymentsData, + null, + state, + [], + [{ type: 'receiveDeploymentsDataFailure' }], + () => { + expect(createFlash).toHaveBeenCalled(); + }, + ); + }); + }); + + // Environments + + describe('fetchEnvironmentsData', () => { + beforeEach(() => { + state.projectPath = 'gitlab-org/gitlab-test'; + }); + + it('setting SET_ENVIRONMENTS_FILTER should dispatch fetchEnvironmentsData', () => { + jest.spyOn(gqClient, 'mutate').mockReturnValue({ + data: { + project: { + data: { + environments: [], + }, + }, + }, + }); + + return testAction( + filterEnvironments, + {}, + state, + [ + { + type: 'SET_ENVIRONMENTS_FILTER', + payload: {}, + }, + ], + [ + { + type: 'fetchEnvironmentsData', + }, + ], + ); + }); + + it('fetch environments data call takes in search param', () => { + const mockMutate = jest.spyOn(gqClient, 'mutate'); + const searchTerm = 'Something'; + const mutationVariables = { + mutation: getEnvironments, + variables: { + projectPath: state.projectPath, + search: searchTerm, + states: [ENVIRONMENT_AVAILABLE_STATE], + }, + }; + state.environmentsSearchTerm = searchTerm; + mockMutate.mockResolvedValue({}); + + return testAction( + fetchEnvironmentsData, + null, + state, + [], + [ + { type: 'requestEnvironmentsData' }, + { type: 'receiveEnvironmentsDataSuccess', payload: [] }, + ], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveEnvironmentsDataSuccess on success', () => { + jest.spyOn(gqClient, 'mutate').mockResolvedValue({ + data: { + project: { + data: { + environments: environmentData, + }, + }, + }, + }); + + return testAction( + fetchEnvironmentsData, + null, + state, + [], + [ + { type: 'requestEnvironmentsData' }, + { + type: 'receiveEnvironmentsDataSuccess', + payload: parseEnvironmentsResponse(environmentData, state.projectPath), + }, + ], + ); + }); + + it('dispatches receiveEnvironmentsDataFailure on error', () => { + jest.spyOn(gqClient, 'mutate').mockRejectedValue({}); + + return testAction( + fetchEnvironmentsData, + null, + state, + [], + [{ type: 'requestEnvironmentsData' }, { type: 'receiveEnvironmentsDataFailure' }], + ); + }); + }); + + describe('fetchAnnotations', () => { + beforeEach(() => { + state.timeRange = { + start: '2020-04-15T12:54:32.137Z', + end: '2020-08-15T12:54:32.137Z', + }; + state.projectPath = 'gitlab-org/gitlab-test'; + state.currentEnvironmentName = 'production'; + state.currentDashboard = '.gitlab/dashboards/custom_dashboard.yml'; + // testAction doesn't have access to getters. The state is passed in as getters + // instead of the actual getters inside the testAction method implementation. + // All methods downstream that needs access to getters will throw and error. + // For that reason, the result of the getter is set as a state variable. + state.fullDashboardPath = store.getters['monitoringDashboard/fullDashboardPath']; + }); + + it('fetches annotations data and dispatches receiveAnnotationsSuccess', () => { + const mockMutate = jest.spyOn(gqClient, 'mutate'); + const mutationVariables = { + mutation: getAnnotations, + variables: { + projectPath: state.projectPath, + environmentName: state.currentEnvironmentName, + dashboardPath: state.currentDashboard, + startingFrom: state.timeRange.start, + }, + }; + const parsedResponse = parseAnnotationsResponse(annotationsData); + + mockMutate.mockResolvedValue({ + data: { + project: { + environments: { + nodes: [ + { + metricsDashboard: { + annotations: { + nodes: parsedResponse, + }, + }, + }, + ], + }, + }, + }, + }); + + return testAction( + fetchAnnotations, + null, + state, + [], + [{ type: 'receiveAnnotationsSuccess', payload: parsedResponse }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveAnnotationsFailure if the annotations API call fails', () => { + const mockMutate = jest.spyOn(gqClient, 'mutate'); + const mutationVariables = { + mutation: getAnnotations, + variables: { + projectPath: state.projectPath, + environmentName: state.currentEnvironmentName, + dashboardPath: state.currentDashboard, + startingFrom: state.timeRange.start, + }, + }; + + mockMutate.mockRejectedValue({}); + + return testAction( + fetchAnnotations, + null, + state, + [], + [{ type: 'receiveAnnotationsFailure' }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + }); + + describe('fetchDashboardValidationWarnings', () => { + let mockMutate; + let mutationVariables; + + beforeEach(() => { + state.projectPath = 'gitlab-org/gitlab-test'; + state.currentEnvironmentName = 'production'; + state.currentDashboard = '.gitlab/dashboards/dashboard_with_warnings.yml'; + // testAction doesn't have access to getters. The state is passed in as getters + // instead of the actual getters inside the testAction method implementation. + // All methods downstream that needs access to getters will throw and error. + // For that reason, the result of the getter is set as a state variable. + state.fullDashboardPath = store.getters['monitoringDashboard/fullDashboardPath']; + + mockMutate = jest.spyOn(gqClient, 'mutate'); + mutationVariables = { + mutation: getDashboardValidationWarnings, + variables: { + projectPath: state.projectPath, + environmentName: state.currentEnvironmentName, + dashboardPath: state.fullDashboardPath, + }, + }; + }); + + it('dispatches receiveDashboardValidationWarningsSuccess with true payload when there are warnings', () => { + mockMutate.mockResolvedValue({ + data: { + project: { + id: 'gid://gitlab/Project/29', + environments: { + nodes: [ + { + name: 'production', + metricsDashboard: { + path: '.gitlab/dashboards/dashboard_errors_test.yml', + schemaValidationWarnings: ["unit: can't be blank"], + }, + }, + ], + }, + }, + }, + }); + + return testAction( + fetchDashboardValidationWarnings, + null, + state, + [], + [{ type: 'receiveDashboardValidationWarningsSuccess', payload: true }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveDashboardValidationWarningsSuccess with false payload when there are no warnings', () => { + mockMutate.mockResolvedValue({ + data: { + project: { + id: 'gid://gitlab/Project/29', + environments: { + nodes: [ + { + name: 'production', + metricsDashboard: { + path: '.gitlab/dashboards/dashboard_errors_test.yml', + schemaValidationWarnings: [], + }, + }, + ], + }, + }, + }, + }); + + return testAction( + fetchDashboardValidationWarnings, + null, + state, + [], + [{ type: 'receiveDashboardValidationWarningsSuccess', payload: false }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveDashboardValidationWarningsSuccess with false payload when the response is empty ', () => { + mockMutate.mockResolvedValue({ + data: { + project: null, + }, + }); + + return testAction( + fetchDashboardValidationWarnings, + null, + state, + [], + [{ type: 'receiveDashboardValidationWarningsSuccess', payload: false }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + + it('dispatches receiveDashboardValidationWarningsFailure if the warnings API call fails', () => { + mockMutate.mockRejectedValue({}); + + return testAction( + fetchDashboardValidationWarnings, + null, + state, + [], + [{ type: 'receiveDashboardValidationWarningsFailure' }], + () => { + expect(mockMutate).toHaveBeenCalledWith(mutationVariables); + }, + ); + }); + }); + + // Dashboard manipulation + + describe('toggleStarredValue', () => { + let unstarredDashboard; + let starredDashboard; + + beforeEach(() => { + state.isUpdatingStarredValue = false; + [unstarredDashboard, starredDashboard] = dashboardGitResponse; + }); + + it('performs no changes if no dashboard is selected', () => { + return testAction(toggleStarredValue, null, state, [], []); + }); + + it('performs no changes if already changing starred value', () => { + state.selectedDashboard = unstarredDashboard; + state.isUpdatingStarredValue = true; + return testAction(toggleStarredValue, null, state, [], []); + }); + + it('stars dashboard if it is not starred', () => { + state.selectedDashboard = unstarredDashboard; + mock.onPost(unstarredDashboard.user_starred_path).reply(200); + + return testAction(toggleStarredValue, null, state, [ + { type: types.REQUEST_DASHBOARD_STARRING }, + { + type: types.RECEIVE_DASHBOARD_STARRING_SUCCESS, + payload: { + newStarredValue: true, + selectedDashboard: unstarredDashboard, + }, + }, + ]); + }); + + it('unstars dashboard if it is starred', () => { + state.selectedDashboard = starredDashboard; + mock.onPost(starredDashboard.user_starred_path).reply(200); + + return testAction(toggleStarredValue, null, state, [ + { type: types.REQUEST_DASHBOARD_STARRING }, + { type: types.RECEIVE_DASHBOARD_STARRING_FAILURE }, + ]); + }); + }); + describe('duplicateSystemDashboard', () => { beforeEach(() => { state.dashboardsEndpoint = '/dashboards.json'; @@ -979,30 +1114,95 @@ describe('Monitoring store actions', () => { }); }); - describe('setExpandedPanel', () => { - it('Sets a panel as expanded', () => { - const group = 'group_1'; - const panel = { title: 'A Panel' }; + // Variables manipulation - return testAction( - setExpandedPanel, - { group, panel }, + describe('updateVariablesAndFetchData', () => { + it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', done => { + testAction( + updateVariablesAndFetchData, + { pod: 'POD' }, state, - [{ type: types.SET_EXPANDED_PANEL, payload: { group, panel } }], - [], + [ + { + type: types.UPDATE_VARIABLE_VALUE, + payload: { pod: 'POD' }, + }, + ], + [ + { + type: 'fetchDashboardData', + }, + ], + done, ); }); }); - describe('clearExpandedPanel', () => { - it('Clears a panel as expanded', () => { + describe('fetchVariableMetricLabelValues', () => { + const variable = { + type: 'metric_label_values', + name: 'label1', + options: { + prometheusEndpointPath: '/series?match[]=metric_name', + label: 'job', + }, + }; + + const defaultQueryParams = { + start_time: '2019-08-06T12:40:02.184Z', + end_time: '2019-08-06T20:40:02.184Z', + }; + + beforeEach(() => { + state = { + ...state, + timeRange: defaultTimeRange, + variables: [variable], + }; + }); + + it('should commit UPDATE_VARIABLE_METRIC_LABEL_VALUES mutation and fetch data', () => { + const data = [ + { + __name__: 'up', + job: 'prometheus', + }, + { + __name__: 'up', + job: 'POD', + }, + ]; + + mock.onGet('/series?match[]=metric_name').reply(200, { + status: 'success', + data, + }); + return testAction( - clearExpandedPanel, - undefined, + fetchVariableMetricLabelValues, + { defaultQueryParams }, state, - [{ type: types.SET_EXPANDED_PANEL, payload: { group: null, panel: null } }], + [ + { + type: types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, + payload: { variable, label: 'job', data }, + }, + ], [], ); }); + + it('should notify the user that dynamic options were not loaded', () => { + mock.onGet('/series?match[]=metric_name').reply(500); + + return testAction(fetchVariableMetricLabelValues, { defaultQueryParams }, state, [], []).then( + () => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + expect.stringContaining('error getting options for variable "label1"'), + ); + }, + ); + }); }); }); diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index 933ccb1e46c..a69f5265ea7 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -4,10 +4,11 @@ import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import { metricStates } from '~/monitoring/constants'; import { + customDashboardBasePath, environmentData, metricsResult, dashboardGitResponse, - mockTemplatingDataResponses, + storeVariables, mockLinks, } from '../mock_data'; import { @@ -27,7 +28,10 @@ describe('Monitoring store Getters', () => { const { metricId } = state.dashboard.panelGroups[group].panels[panel].metrics[metric]; mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, - result, + data: { + resultType: 'matrix', + result, + }, }); }; @@ -340,19 +344,21 @@ describe('Monitoring store Getters', () => { }); it('transforms the variables object to an array in the [variable, variable_value] format for all variable types', () => { - mutations[types.SET_VARIABLES](state, mockTemplatingDataResponses.allVariableTypes); + state.variables = storeVariables; const variablesArray = getters.getCustomVariablesParams(state); expect(variablesArray).toEqual({ - 'variables[advCustomNormal]': 'value2', - 'variables[advText]': 'default', - 'variables[simpleCustom]': 'value1', - 'variables[simpleText]': 'Simple text', + 'variables[textSimple]': 'My default value', + 'variables[textAdvanced]': 'A default value', + 'variables[customSimple]': 'value1', + 'variables[customAdvanced]': 'value2', + 'variables[customAdvancedWithoutLabel]': 'value2', + 'variables[customAdvancedWithoutOptText]': 'value2', }); }); it('transforms the variables object to an empty array when no keys are present', () => { - mutations[types.SET_VARIABLES](state, {}); + state.variables = []; const variablesArray = getters.getCustomVariablesParams(state); expect(variablesArray).toEqual({}); @@ -361,45 +367,53 @@ describe('Monitoring store Getters', () => { describe('selectedDashboard', () => { const { selectedDashboard } = getters; + const localGetters = state => ({ + fullDashboardPath: getters.fullDashboardPath(state), + }); it('returns a dashboard', () => { const state = { allDashboards: dashboardGitResponse, currentDashboard: dashboardGitResponse[0].path, + customDashboardBasePath, }; - expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); }); it('returns a non-default dashboard', () => { const state = { allDashboards: dashboardGitResponse, currentDashboard: dashboardGitResponse[1].path, + customDashboardBasePath, }; - expect(selectedDashboard(state)).toEqual(dashboardGitResponse[1]); + expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[1]); }); it('returns a default dashboard when no dashboard is selected', () => { const state = { allDashboards: dashboardGitResponse, currentDashboard: null, + customDashboardBasePath, }; - expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); }); it('returns a default dashboard when dashboard cannot be found', () => { const state = { allDashboards: dashboardGitResponse, currentDashboard: 'wrong_path', + customDashboardBasePath, }; - expect(selectedDashboard(state)).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); }); it('returns null when no dashboards are present', () => { const state = { allDashboards: [], currentDashboard: dashboardGitResponse[0].path, + customDashboardBasePath, }; - expect(selectedDashboard(state)).toEqual(null); + expect(selectedDashboard(state, localGetters(state))).toEqual(null); }); }); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 0283f1a86a4..14b38d79aa2 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -3,9 +3,9 @@ import httpStatusCodes from '~/lib/utils/http_status'; import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import state from '~/monitoring/stores/state'; -import { metricStates } from '~/monitoring/constants'; +import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; -import { deploymentData, dashboardGitResponse } from '../mock_data'; +import { deploymentData, dashboardGitResponse, storeTextVariables } from '../mock_data'; import { metricsDashboardPayload } from '../fixture_data'; describe('Monitoring mutations', () => { @@ -15,6 +15,14 @@ describe('Monitoring mutations', () => { stateCopy = state(); }); + describe('REQUEST_METRICS_DASHBOARD', () => { + it('sets an empty loading state', () => { + mutations[types.REQUEST_METRICS_DASHBOARD](stateCopy); + + expect(stateCopy.emptyState).toBe(dashboardEmptyStates.LOADING); + }); + }); + describe('RECEIVE_METRICS_DASHBOARD_SUCCESS', () => { let payload; const getGroups = () => stateCopy.dashboard.panelGroups; @@ -23,6 +31,18 @@ describe('Monitoring mutations', () => { stateCopy.dashboard.panelGroups = []; payload = metricsDashboardPayload; }); + it('sets an empty noData state when the dashboard is empty', () => { + const emptyDashboardPayload = { + ...payload, + panel_groups: [], + }; + + mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, emptyDashboardPayload); + const groups = getGroups(); + + expect(groups).toEqual([]); + expect(stateCopy.emptyState).toBe(dashboardEmptyStates.NO_DATA); + }); it('adds a key to the group', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, payload); const groups = getGroups(); @@ -72,6 +92,20 @@ describe('Monitoring mutations', () => { }); }); + describe('RECEIVE_METRICS_DASHBOARD_FAILURE', () => { + it('sets an empty noData state when an empty error occurs', () => { + mutations[types.RECEIVE_METRICS_DASHBOARD_FAILURE](stateCopy); + + expect(stateCopy.emptyState).toBe(dashboardEmptyStates.NO_DATA); + }); + + it('sets an empty unableToConnect state when an error occurs', () => { + mutations[types.RECEIVE_METRICS_DASHBOARD_FAILURE](stateCopy, 'myerror'); + + expect(stateCopy.emptyState).toBe(dashboardEmptyStates.UNABLE_TO_CONNECT); + }); + }); + describe('Dashboard starring mutations', () => { it('REQUEST_DASHBOARD_STARRING', () => { stateCopy = { isUpdatingStarredValue: false }; @@ -225,11 +259,28 @@ describe('Monitoring mutations', () => { describe('Individual panel/metric results', () => { const metricId = 'NO_DB_response_metrics_nginx_ingress_throughput_status_code'; - const result = [ - { - values: [[0, 1], [1, 1], [1, 3]], - }, - ]; + const data = { + resultType: 'matrix', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + values: [[1435781430.781, '1'], [1435781445.781, '1'], [1435781460.781, '1']], + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9091', + }, + values: [[1435781430.781, '0'], [1435781445.781, '0'], [1435781460.781, '1']], + }, + ], + }; + const dashboard = metricsDashboardPayload; const getMetric = () => stateCopy.dashboard.panelGroups[1].panels[0].metrics[0]; @@ -238,13 +289,10 @@ describe('Monitoring mutations', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, dashboard); }); it('stores a loading state on a metric', () => { - expect(stateCopy.showEmptyState).toBe(true); - mutations[types.REQUEST_METRIC_RESULT](stateCopy, { metricId, }); - expect(stateCopy.showEmptyState).toBe(true); expect(getMetric()).toEqual( expect.objectContaining({ loading: true, @@ -257,26 +305,16 @@ describe('Monitoring mutations', () => { beforeEach(() => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, dashboard); }); - it('clears empty state', () => { - expect(stateCopy.showEmptyState).toBe(true); - - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](stateCopy, { - metricId, - result, - }); - - expect(stateCopy.showEmptyState).toBe(false); - }); it('adds results to the store', () => { expect(getMetric().result).toBe(null); mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](stateCopy, { metricId, - result, + data, }); - expect(getMetric().result).toHaveLength(result.length); + expect(getMetric().result).toHaveLength(data.result.length); expect(getMetric()).toEqual( expect.objectContaining({ loading: false, @@ -290,16 +328,6 @@ describe('Monitoring mutations', () => { beforeEach(() => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](stateCopy, dashboard); }); - it('maintains the loading state when a metric fails', () => { - expect(stateCopy.showEmptyState).toBe(true); - - mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { - metricId, - error: 'an error', - }); - - expect(stateCopy.showEmptyState).toBe(true); - }); it('stores a timeout error in a metric', () => { mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { @@ -369,6 +397,7 @@ describe('Monitoring mutations', () => { }); }); }); + describe('SET_ALL_DASHBOARDS', () => { it('stores `undefined` dashboards as an empty array', () => { mutations[types.SET_ALL_DASHBOARDS](stateCopy, undefined); @@ -410,30 +439,53 @@ describe('Monitoring mutations', () => { }); }); - describe('SET_VARIABLES', () => { - it('stores an empty variables array when no custom variables are given', () => { - mutations[types.SET_VARIABLES](stateCopy, {}); - - expect(stateCopy.variables).toEqual({}); - }); - - it('stores variables in the key key_value format in the array', () => { - mutations[types.SET_VARIABLES](stateCopy, { pod: 'POD', stage: 'main ops' }); + describe('UPDATE_VARIABLE_VALUE', () => { + it('updates only the value of the variable in variables', () => { + stateCopy.variables = storeTextVariables; + mutations[types.UPDATE_VARIABLE_VALUE](stateCopy, { name: 'textSimple', value: 'New Value' }); - expect(stateCopy.variables).toEqual({ pod: 'POD', stage: 'main ops' }); + expect(stateCopy.variables[0].value).toEqual('New Value'); }); }); - describe('UPDATE_VARIABLES', () => { - afterEach(() => { - mutations[types.SET_VARIABLES](stateCopy, {}); - }); - - it('updates only the value of the variable in variables', () => { - mutations[types.SET_VARIABLES](stateCopy, { environment: { value: 'prod', type: 'text' } }); - mutations[types.UPDATE_VARIABLES](stateCopy, { key: 'environment', value: 'new prod' }); + describe('UPDATE_VARIABLE_METRIC_LABEL_VALUES', () => { + it('updates options in a variable', () => { + const data = [ + { + __name__: 'up', + job: 'prometheus', + env: 'prd', + }, + { + __name__: 'up', + job: 'prometheus', + env: 'stg', + }, + { + __name__: 'up', + job: 'node', + env: 'prod', + }, + { + __name__: 'up', + job: 'node', + env: 'stg', + }, + ]; + + const variable = { + options: {}, + }; + + mutations[types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](stateCopy, { + variable, + label: 'job', + data, + }); - expect(stateCopy.variables).toEqual({ environment: { value: 'new prod', type: 'text' } }); + expect(variable.options).toEqual({ + values: [{ text: 'prometheus', value: 'prometheus' }, { text: 'node', value: 'node' }], + }); }); }); }); diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js index 2dea40585f1..b97948fa1bf 100644 --- a/spec/frontend/monitoring/store/utils_spec.js +++ b/spec/frontend/monitoring/store/utils_spec.js @@ -5,9 +5,10 @@ import { parseAnnotationsResponse, removeLeadingSlash, mapToDashboardViewModel, - normalizeQueryResult, + normalizeQueryResponseData, convertToGrafanaTimeRange, addDashboardMetaDataToLink, + normalizeCustomDashboardPath, } from '~/monitoring/stores/utils'; import * as urlUtils from '~/lib/utils/url_utility'; import { annotationsData } from '../mock_data'; @@ -21,7 +22,7 @@ describe('mapToDashboardViewModel', () => { dashboard: '', panelGroups: [], links: [], - variables: {}, + variables: [], }); }); @@ -51,7 +52,7 @@ describe('mapToDashboardViewModel', () => { expect(mapToDashboardViewModel(response)).toEqual({ dashboard: 'Dashboard Name', links: [], - variables: {}, + variables: [], panelGroups: [ { group: 'Group 1', @@ -423,22 +424,20 @@ describe('mapToDashboardViewModel', () => { urlUtils.queryToObject.mockReturnValueOnce(); - expect(mapToDashboardViewModel(response)).toMatchObject({ - dashboard: 'Dashboard Name', - links: [], - variables: { - pod: { - label: 'pod', - type: 'text', - value: 'kubernetes', - }, - pod_2: { - label: 'pod_2', - type: 'text', - value: 'kubernetes-2', - }, + expect(mapToDashboardViewModel(response).variables).toEqual([ + { + name: 'pod', + label: 'pod', + type: 'text', + value: 'kubernetes', }, - }); + { + name: 'pod_2', + label: 'pod_2', + type: 'text', + value: 'kubernetes-2', + }, + ]); }); it('sets variables as-is from yml file if URL has no matching variables', () => { @@ -457,22 +456,20 @@ describe('mapToDashboardViewModel', () => { 'var-environment': 'POD', }); - expect(mapToDashboardViewModel(response)).toMatchObject({ - dashboard: 'Dashboard Name', - links: [], - variables: { - pod: { - label: 'pod', - type: 'text', - value: 'kubernetes', - }, - pod_2: { - label: 'pod_2', - type: 'text', - value: 'kubernetes-2', - }, + expect(mapToDashboardViewModel(response).variables).toEqual([ + { + label: 'pod', + name: 'pod', + type: 'text', + value: 'kubernetes', }, - }); + { + label: 'pod_2', + name: 'pod_2', + type: 'text', + value: 'kubernetes-2', + }, + ]); }); it('merges variables from URL with the ones from yml file', () => { @@ -493,44 +490,20 @@ describe('mapToDashboardViewModel', () => { 'var-pod_2': 'POD2', }); - expect(mapToDashboardViewModel(response)).toMatchObject({ - dashboard: 'Dashboard Name', - links: [], - variables: { - pod: { - label: 'pod', - type: 'text', - value: 'POD1', - }, - pod_2: { - label: 'pod_2', - type: 'text', - value: 'POD2', - }, + expect(mapToDashboardViewModel(response).variables).toEqual([ + { + label: 'pod', + name: 'pod', + type: 'text', + value: 'POD1', }, - }); - }); - }); -}); - -describe('normalizeQueryResult', () => { - const testData = { - metric: { - __name__: 'up', - job: 'prometheus', - instance: 'localhost:9090', - }, - values: [[1435781430.781, '1'], [1435781445.781, '1'], [1435781460.781, '1']], - }; - - it('processes a simple matrix result', () => { - expect(normalizeQueryResult(testData)).toEqual({ - metric: { __name__: 'up', job: 'prometheus', instance: 'localhost:9090' }, - values: [ - ['2015-07-01T20:10:30.781Z', 1], - ['2015-07-01T20:10:45.781Z', 1], - ['2015-07-01T20:11:00.781Z', 1], - ], + { + label: 'pod_2', + name: 'pod_2', + type: 'text', + value: 'POD2', + }, + ]); }); }); }); @@ -720,3 +693,187 @@ describe('user-defined links utils', () => { }); }); }); + +describe('normalizeQueryResponseData', () => { + // Data examples from + // https://prometheus.io/docs/prometheus/latest/querying/api/#expression-queries + + it('processes a string result', () => { + const mockScalar = { + resultType: 'string', + result: [1435781451.781, '1'], + }; + + expect(normalizeQueryResponseData(mockScalar)).toEqual([ + { + metric: {}, + value: ['2015-07-01T20:10:51.781Z', '1'], + values: [['2015-07-01T20:10:51.781Z', '1']], + }, + ]); + }); + + it('processes a scalar result', () => { + const mockScalar = { + resultType: 'scalar', + result: [1435781451.781, '1'], + }; + + expect(normalizeQueryResponseData(mockScalar)).toEqual([ + { + metric: {}, + value: ['2015-07-01T20:10:51.781Z', 1], + values: [['2015-07-01T20:10:51.781Z', 1]], + }, + ]); + }); + + it('processes a vector result', () => { + const mockVector = { + resultType: 'vector', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + value: [1435781451.781, '1'], + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9100', + }, + value: [1435781451.781, '0'], + }, + ], + }; + + expect(normalizeQueryResponseData(mockVector)).toEqual([ + { + metric: { __name__: 'up', job: 'prometheus', instance: 'localhost:9090' }, + value: ['2015-07-01T20:10:51.781Z', 1], + values: [['2015-07-01T20:10:51.781Z', 1]], + }, + { + metric: { __name__: 'up', job: 'node', instance: 'localhost:9100' }, + value: ['2015-07-01T20:10:51.781Z', 0], + values: [['2015-07-01T20:10:51.781Z', 0]], + }, + ]); + }); + + it('processes a matrix result', () => { + const mockMatrix = { + resultType: 'matrix', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + values: [[1435781430.781, '1'], [1435781445.781, '2'], [1435781460.781, '3']], + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9091', + }, + values: [[1435781430.781, '4'], [1435781445.781, '5'], [1435781460.781, '6']], + }, + ], + }; + + expect(normalizeQueryResponseData(mockMatrix)).toEqual([ + { + metric: { __name__: 'up', instance: 'localhost:9090', job: 'prometheus' }, + value: ['2015-07-01T20:11:00.781Z', 3], + values: [ + ['2015-07-01T20:10:30.781Z', 1], + ['2015-07-01T20:10:45.781Z', 2], + ['2015-07-01T20:11:00.781Z', 3], + ], + }, + { + metric: { __name__: 'up', instance: 'localhost:9091', job: 'node' }, + value: ['2015-07-01T20:11:00.781Z', 6], + values: [ + ['2015-07-01T20:10:30.781Z', 4], + ['2015-07-01T20:10:45.781Z', 5], + ['2015-07-01T20:11:00.781Z', 6], + ], + }, + ]); + }); + + it('processes a scalar result with a NaN result', () => { + // Queries may return "NaN" string values. + // e.g. when Prometheus cannot find a metric the query + // `scalar(does_not_exist)` will return a "NaN" value. + + const mockScalar = { + resultType: 'scalar', + result: [1435781451.781, 'NaN'], + }; + + expect(normalizeQueryResponseData(mockScalar)).toEqual([ + { + metric: {}, + value: ['2015-07-01T20:10:51.781Z', NaN], + values: [['2015-07-01T20:10:51.781Z', NaN]], + }, + ]); + }); + + it('processes a matrix result with a "NaN" value', () => { + // Queries may return "NaN" string values. + const mockMatrix = { + resultType: 'matrix', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + values: [[1435781430.781, '1'], [1435781460.781, 'NaN']], + }, + ], + }; + + expect(normalizeQueryResponseData(mockMatrix)).toEqual([ + { + metric: { __name__: 'up', instance: 'localhost:9090', job: 'prometheus' }, + value: ['2015-07-01T20:11:00.781Z', NaN], + values: [['2015-07-01T20:10:30.781Z', 1], ['2015-07-01T20:11:00.781Z', NaN]], + }, + ]); + }); +}); + +describe('normalizeCustomDashboardPath', () => { + it.each` + input | expected + ${[undefined]} | ${''} + ${[null]} | ${''} + ${[]} | ${''} + ${['links.yml']} | ${'links.yml'} + ${['links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/links.yml'} + ${['config/prometheus/common_metrics.yml']} | ${'config/prometheus/common_metrics.yml'} + ${['config/prometheus/common_metrics.yml', '.gitlab/dashboards']} | ${'config/prometheus/common_metrics.yml'} + ${['dir1/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/links.yml'} + ${['dir1/dir2/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/dir2/links.yml'} + ${['.gitlab/dashboards/links.yml']} | ${'.gitlab/dashboards/links.yml'} + ${['.gitlab/dashboards/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/links.yml'} + ${['.gitlab/dashboards/dir1/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/links.yml'} + ${['.gitlab/dashboards/dir1/dir2/links.yml', '.gitlab/dashboards']} | ${'.gitlab/dashboards/dir1/dir2/links.yml'} + ${['config/prometheus/pod_metrics.yml', '.gitlab/dashboards']} | ${'config/prometheus/pod_metrics.yml'} + ${['config/prometheus/pod_metrics.yml']} | ${'config/prometheus/pod_metrics.yml'} + `(`normalizeCustomDashboardPath returns $expected for $input`, ({ input, expected }) => { + expect(normalizeCustomDashboardPath(...input)).toEqual(expected); + }); +}); diff --git a/spec/frontend/monitoring/store/variable_mapping_spec.js b/spec/frontend/monitoring/store/variable_mapping_spec.js index 5164ed1b54b..de124b0313c 100644 --- a/spec/frontend/monitoring/store/variable_mapping_spec.js +++ b/spec/frontend/monitoring/store/variable_mapping_spec.js @@ -1,94 +1,209 @@ -import { parseTemplatingVariables, mergeURLVariables } from '~/monitoring/stores/variable_mapping'; +import { + parseTemplatingVariables, + mergeURLVariables, + optionsFromSeriesData, +} from '~/monitoring/stores/variable_mapping'; +import { + templatingVariablesExamples, + storeTextVariables, + storeCustomVariables, + storeMetricLabelValuesVariables, +} from '../mock_data'; import * as urlUtils from '~/lib/utils/url_utility'; -import { mockTemplatingData, mockTemplatingDataResponses } from '../mock_data'; - -describe('parseTemplatingVariables', () => { - it.each` - case | input | expected - ${'Returns empty object for no dashboard input'} | ${{}} | ${{}} - ${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}} - ${'Returns empty object for empty templating prop'} | ${mockTemplatingData.emptyTemplatingProp} | ${{}} - ${'Returns empty object for empty variables prop'} | ${mockTemplatingData.emptyVariablesProp} | ${{}} - ${'Returns parsed object for simple text variable'} | ${mockTemplatingData.simpleText} | ${mockTemplatingDataResponses.simpleText} - ${'Returns parsed object for advanced text variable'} | ${mockTemplatingData.advText} | ${mockTemplatingDataResponses.advText} - ${'Returns parsed object for simple custom variable'} | ${mockTemplatingData.simpleCustom} | ${mockTemplatingDataResponses.simpleCustom} - ${'Returns parsed object for advanced custom variable without options'} | ${mockTemplatingData.advCustomWithoutOpts} | ${mockTemplatingDataResponses.advCustomWithoutOpts} - ${'Returns parsed object for advanced custom variable for option without text'} | ${mockTemplatingData.advCustomWithoutOptText} | ${mockTemplatingDataResponses.advCustomWithoutOptText} - ${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}} - ${'Returns parsed object for advanced custom variable without label'} | ${mockTemplatingData.advCustomWithoutLabel} | ${mockTemplatingDataResponses.advCustomWithoutLabel} - ${'Returns parsed object for simple and advanced custom variables'} | ${mockTemplatingData.simpleAndAdv} | ${mockTemplatingDataResponses.simpleAndAdv} - ${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes} - `('$case', ({ input, expected }) => { - expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected); - }); -}); -describe('mergeURLVariables', () => { - beforeEach(() => { - jest.spyOn(urlUtils, 'queryToObject'); - }); +describe('Monitoring variable mapping', () => { + describe('parseTemplatingVariables', () => { + it.each` + case | input + ${'For undefined templating object'} | ${undefined} + ${'For empty templating object'} | ${{}} + `('$case, returns an empty array', ({ input }) => { + expect(parseTemplatingVariables(input)).toEqual([]); + }); - afterEach(() => { - urlUtils.queryToObject.mockRestore(); + it.each` + case | input | output + ${'Returns parsed object for text variables'} | ${templatingVariablesExamples.text} | ${storeTextVariables} + ${'Returns parsed object for custom variables'} | ${templatingVariablesExamples.custom} | ${storeCustomVariables} + ${'Returns parsed object for metric label value variables'} | ${templatingVariablesExamples.metricLabelValues} | ${storeMetricLabelValuesVariables} + `('$case, returns an empty array', ({ input, output }) => { + expect(parseTemplatingVariables(input)).toEqual(output); + }); }); - it('returns empty object if variables are not defined in yml or URL', () => { - urlUtils.queryToObject.mockReturnValueOnce({}); + describe('mergeURLVariables', () => { + beforeEach(() => { + jest.spyOn(urlUtils, 'queryToObject'); + }); - expect(mergeURLVariables({})).toEqual({}); - }); + afterEach(() => { + urlUtils.queryToObject.mockRestore(); + }); - it('returns empty object if variables are defined in URL but not in yml', () => { - urlUtils.queryToObject.mockReturnValueOnce({ - 'var-env': 'one', - 'var-instance': 'localhost', + it('returns empty object if variables are not defined in yml or URL', () => { + urlUtils.queryToObject.mockReturnValueOnce({}); + + expect(mergeURLVariables([])).toEqual([]); }); - expect(mergeURLVariables({})).toEqual({}); - }); + it('returns empty object if variables are defined in URL but not in yml', () => { + urlUtils.queryToObject.mockReturnValueOnce({ + 'var-env': 'one', + 'var-instance': 'localhost', + }); - it('returns yml variables if variables defined in yml but not in the URL', () => { - urlUtils.queryToObject.mockReturnValueOnce({}); + expect(mergeURLVariables([])).toEqual([]); + }); - const params = { - env: 'one', - instance: 'localhost', - }; + it('returns yml variables if variables defined in yml but not in the URL', () => { + urlUtils.queryToObject.mockReturnValueOnce({}); + + const variables = [ + { + name: 'env', + value: 'one', + }, + { + name: 'instance', + value: 'localhost', + }, + ]; + + expect(mergeURLVariables(variables)).toEqual(variables); + }); - expect(mergeURLVariables(params)).toEqual(params); - }); + it('returns yml variables if variables defined in URL do not match with yml variables', () => { + const urlParams = { + 'var-env': 'one', + 'var-instance': 'localhost', + }; + const variables = [ + { + name: 'env', + value: 'one', + }, + { + name: 'service', + value: 'database', + }, + ]; + urlUtils.queryToObject.mockReturnValueOnce(urlParams); + + expect(mergeURLVariables(variables)).toEqual(variables); + }); - it('returns yml variables if variables defined in URL do not match with yml variables', () => { - const urlParams = { - 'var-env': 'one', - 'var-instance': 'localhost', - }; - const ymlParams = { - pod: { value: 'one' }, - service: { value: 'database' }, - }; - urlUtils.queryToObject.mockReturnValueOnce(urlParams); - - expect(mergeURLVariables(ymlParams)).toEqual(ymlParams); + it('returns merged yml and URL variables if there is some match', () => { + const urlParams = { + 'var-env': 'one', + 'var-instance': 'localhost:8080', + }; + const variables = [ + { + name: 'instance', + value: 'localhost', + }, + { + name: 'service', + value: 'database', + }, + ]; + + urlUtils.queryToObject.mockReturnValueOnce(urlParams); + + expect(mergeURLVariables(variables)).toEqual([ + { + name: 'instance', + value: 'localhost:8080', + }, + { + name: 'service', + value: 'database', + }, + ]); + }); }); - it('returns merged yml and URL variables if there is some match', () => { - const urlParams = { - 'var-env': 'one', - 'var-instance': 'localhost:8080', - }; - const ymlParams = { - instance: { value: 'localhost' }, - service: { value: 'database' }, - }; + describe('optionsFromSeriesData', () => { + it('fetches the label values from missing data', () => { + expect(optionsFromSeriesData({ label: 'job' })).toEqual([]); + }); - const merged = { - instance: { value: 'localhost:8080' }, - service: { value: 'database' }, - }; + it('fetches the label values from a simple series', () => { + const data = [ + { + __name__: 'up', + job: 'job1', + }, + { + __name__: 'up', + job: 'job2', + }, + ]; + + expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ + { text: 'job1', value: 'job1' }, + { text: 'job2', value: 'job2' }, + ]); + }); - urlUtils.queryToObject.mockReturnValueOnce(urlParams); + it('fetches the label values from multiple series', () => { + const data = [ + { + __name__: 'up', + job: 'job1', + instance: 'host1', + }, + { + __name__: 'up', + job: 'job2', + instance: 'host1', + }, + { + __name__: 'up', + job: 'job1', + instance: 'host2', + }, + { + __name__: 'up', + job: 'job2', + instance: 'host2', + }, + ]; + + expect(optionsFromSeriesData({ label: '__name__', data })).toEqual([ + { text: 'up', value: 'up' }, + ]); + + expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ + { text: 'job1', value: 'job1' }, + { text: 'job2', value: 'job2' }, + ]); + + expect(optionsFromSeriesData({ label: 'instance', data })).toEqual([ + { text: 'host1', value: 'host1' }, + { text: 'host2', value: 'host2' }, + ]); + }); - expect(mergeURLVariables(ymlParams)).toEqual(merged); + it('fetches the label values from a series with missing values', () => { + const data = [ + { + __name__: 'up', + job: 'job1', + }, + { + __name__: 'up', + job: 'job2', + }, + { + __name__: 'up', + }, + ]; + + expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ + { text: 'job1', value: 'job1' }, + { text: 'job2', value: 'job2' }, + ]); + }); }); }); diff --git a/spec/frontend/monitoring/store_utils.js b/spec/frontend/monitoring/store_utils.js index eb2578aa9db..6c8267e6a3c 100644 --- a/spec/frontend/monitoring/store_utils.js +++ b/spec/frontend/monitoring/store_utils.js @@ -8,7 +8,10 @@ export const setMetricResult = ({ store, result, group = 0, panel = 0, metric = store.commit(`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`, { metricId, - result, + data: { + resultType: 'matrix', + result, + }, }); }; @@ -32,12 +35,6 @@ export const setupStoreWithDashboard = store => { ); }; -export const setupStoreWithVariable = store => { - store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, { - label1: 'pod', - }); -}; - export const setupStoreWithLinks = store => { store.commit(`monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, { ...metricsDashboardPayload, @@ -60,3 +57,24 @@ export const setupStoreWithData = store => { setEnvironmentData(store); }; + +export const setupStoreWithDataForPanelCount = (store, panelCount) => { + const payloadPanelGroup = metricsDashboardPayload.panel_groups[0]; + + const panelGroupCustom = { + ...payloadPanelGroup, + panels: payloadPanelGroup.panels.slice(0, panelCount), + }; + + const metricsDashboardPayloadCustom = { + ...metricsDashboardPayload, + panel_groups: [panelGroupCustom], + }; + + store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DASHBOARD_SUCCESS}`, + metricsDashboardPayloadCustom, + ); + + setMetricResult({ store, result: metricsResult, panel: 0 }); +}; diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js index 039cf275eea..35ca6ba9b52 100644 --- a/spec/frontend/monitoring/utils_spec.js +++ b/spec/frontend/monitoring/utils_spec.js @@ -1,12 +1,8 @@ import * as monitoringUtils from '~/monitoring/utils'; import * as urlUtils from '~/lib/utils/url_utility'; import { TEST_HOST } from 'jest/helpers/test_constants'; -import { - mockProjectDir, - singleStatMetricsResult, - anomalyMockGraphData, - barMockData, -} from './mock_data'; +import { mockProjectDir, barMockData } from './mock_data'; +import { singleStatGraphData, anomalyGraphData } from './graph_data'; import { metricsDashboardViewModel, graphData } from './fixture_data'; const mockPath = `${TEST_HOST}${mockProjectDir}/-/environments/29/metrics`; @@ -82,7 +78,7 @@ describe('monitoring/utils', () => { it('validates data with the query format', () => { const validGraphData = monitoringUtils.graphDataValidatorForValues( true, - singleStatMetricsResult, + singleStatGraphData(), ); expect(validGraphData).toBe(true); @@ -105,13 +101,13 @@ describe('monitoring/utils', () => { let threeMetrics; let fourMetrics; beforeEach(() => { - oneMetric = singleStatMetricsResult; - threeMetrics = anomalyMockGraphData; + oneMetric = singleStatGraphData(); + threeMetrics = anomalyGraphData(); const metrics = [...threeMetrics.metrics]; metrics.push(threeMetrics.metrics[0]); fourMetrics = { - ...anomalyMockGraphData, + ...anomalyGraphData(), metrics, }; }); @@ -429,14 +425,41 @@ describe('monitoring/utils', () => { describe('convertVariablesForURL', () => { it.each` - input | expected - ${undefined} | ${{}} - ${null} | ${{}} - ${{}} | ${{}} - ${{ env: { value: 'prod' } }} | ${{ 'var-env': 'prod' }} - ${{ 'var-env': { value: 'prod' } }} | ${{ 'var-var-env': 'prod' }} + input | expected + ${[]} | ${{}} + ${[{ name: 'env', value: 'prod' }]} | ${{ 'var-env': 'prod' }} + ${[{ name: 'env1', value: 'prod' }, { name: 'env2', value: null }]} | ${{ 'var-env1': 'prod' }} + ${[{ name: 'var-env', value: 'prod' }]} | ${{ 'var-var-env': 'prod' }} `('convertVariablesForURL returns $expected with input $input', ({ input, expected }) => { expect(monitoringUtils.convertVariablesForURL(input)).toEqual(expected); }); }); + + describe('setCustomVariablesFromUrl', () => { + beforeEach(() => { + jest.spyOn(urlUtils, 'updateHistory'); + }); + + afterEach(() => { + urlUtils.updateHistory.mockRestore(); + }); + + it.each` + input | urlParams + ${[]} | ${''} + ${[{ name: 'env', value: 'prod' }]} | ${'?var-env=prod'} + ${[{ name: 'env1', value: 'prod' }, { name: 'env2', value: null }]} | ${'?var-env=prod&var-env1=prod'} + `( + 'setCustomVariablesFromUrl updates history with query "$urlParams" with input $input', + ({ input, urlParams }) => { + monitoringUtils.setCustomVariablesFromUrl(input); + + expect(urlUtils.updateHistory).toHaveBeenCalledTimes(1); + expect(urlUtils.updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/${urlParams}`, + title: '', + }); + }, + ); + }); }); |