diff options
Diffstat (limited to 'spec/frontend/monitoring')
31 files changed, 2539 insertions, 1108 deletions
diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js index f0355dfa01b..193dbb3e63f 100644 --- a/spec/frontend/monitoring/alert_widget_spec.js +++ b/spec/frontend/monitoring/alert_widget_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { GlLoadingIcon, GlTooltip, GlSprintf, GlBadge } from '@gitlab/ui'; -import AlertWidget from '~/monitoring/components/alert_widget.vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import AlertWidget from '~/monitoring/components/alert_widget.vue'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; const mockReadAlert = jest.fn(); const mockCreateAlert = jest.fn(); 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 e7c51d82cd2..7ef956f8e05 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -20,7 +20,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` data-qa-selector="dashboards_filter_dropdown" defaultbranch="master" id="monitor-dashboards-dropdown" - modalid="duplicateDashboard" toggle-class="dropdown-menu-toggle" /> </div> @@ -33,26 +32,24 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="mb-2 pr-2 d-flex d-sm-block" > - <gl-dropdown-stub + <gl-new-dropdown-stub + category="tertiary" class="flex-grow-1" data-qa-selector="environments_dropdown" + headertext="" id="monitor-environments-dropdown" menu-class="monitor-environment-dropdown-menu" + size="medium" text="production" - toggle-class="dropdown-menu-toggle" + toggleclass="dropdown-menu-toggle" + variant="default" > <div class="d-flex flex-column overflow-hidden" > - <gl-dropdown-header-stub - class="monitor-environment-dropdown-header text-center" - > - - Environment - - </gl-dropdown-header-stub> - - <gl-dropdown-divider-stub /> + <gl-new-dropdown-header-stub> + Environment + </gl-new-dropdown-header-stub> <gl-search-box-by-type-stub class="m-2" @@ -72,7 +69,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` </div> </div> - </gl-dropdown-stub> + </gl-new-dropdown-stub> </div> <div @@ -100,45 +97,23 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="d-sm-flex" > - <div - class="mb-2 mr-2 d-flex" - > - <div - class="flex-grow-1" - title="Star dashboard" - > - <gl-deprecated-button-stub - class="w-100" - size="md" - variant="default" - > - <gl-icon-stub - name="star-o" - size="16" - /> - </gl-deprecated-button-stub> - </div> - </div> - <!----> <!----> - <!----> - - <!----> - - <!----> - - <!----> + <div + class="gl-mb-3 gl-mr-3 d-flex d-sm-block" + > + <actions-menu-stub + custommetricspath="/monitoring/monitor-project/prometheus/metrics" + defaultbranch="master" + isootbdashboard="true" + validatequerypath="/monitoring/monitor-project/prometheus/metrics/validate_query" + /> + </div> <!----> </div> - - <duplicate-dashboard-modal-stub - defaultbranch="master" - modalid="duplicateDashboard" - /> </div> <empty-state-stub diff --git a/spec/frontend/monitoring/components/alert_widget_form_spec.js b/spec/frontend/monitoring/components/alert_widget_form_spec.js index a8416216a94..6d71a9b09e5 100644 --- a/spec/frontend/monitoring/components/alert_widget_form_spec.js +++ b/spec/frontend/monitoring/components/alert_widget_form_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlLink } from '@gitlab/ui'; +import INVALID_URL from '~/lib/utils/invalid_url'; import AlertWidgetForm from '~/monitoring/components/alert_widget_form.vue'; import ModalStub from '../stubs/modal_stub'; @@ -24,7 +25,13 @@ describe('AlertWidgetForm', () => { const propsWithAlertData = { ...defaultProps, alertsToManage: { - alert: { alert_path: alertPath, operator: '<', threshold: 5, metricId }, + alert: { + alert_path: alertPath, + operator: '<', + threshold: 5, + metricId, + runbookUrl: INVALID_URL, + }, }, configuredAlert: metricId, }; @@ -46,15 +53,11 @@ describe('AlertWidgetForm', () => { const modal = () => wrapper.find(ModalStub); const modalTitle = () => modal().attributes('title'); const submitButton = () => modal().find(GlLink); + const findRunbookField = () => modal().find('[data-testid="alertRunbookField"]'); + const findThresholdField = () => modal().find('[data-qa-selector="alert_threshold_field"]'); const submitButtonTrackingOpts = () => JSON.parse(submitButton().attributes('data-tracking-options')); - const e = { - preventDefault: jest.fn(), - }; - - beforeEach(() => { - e.preventDefault.mockReset(); - }); + const stubEvent = { preventDefault: jest.fn() }; afterEach(() => { if (wrapper) wrapper.destroy(); @@ -81,35 +84,34 @@ describe('AlertWidgetForm', () => { expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.create); }); - it('emits a "create" event when form submitted without existing alert', () => { - createComponent(); + it('emits a "create" event when form submitted without existing alert', async () => { + createComponent(defaultProps); - wrapper.vm.selectQuery('9'); - wrapper.setData({ - threshold: 900, - }); + modal().vm.$emit('shown'); + + findThresholdField().vm.$emit('input', 900); + findRunbookField().vm.$emit('input', INVALID_URL); - wrapper.vm.handleSubmit(e); + modal().vm.$emit('ok', stubEvent); expect(wrapper.emitted().create[0]).toEqual([ { alert: undefined, operator: '>', threshold: 900, - prometheus_metric_id: '9', + prometheus_metric_id: '8', + runbookUrl: INVALID_URL, }, ]); - expect(e.preventDefault).toHaveBeenCalledTimes(1); }); it('resets form when modal is dismissed (hidden)', () => { - createComponent(); + createComponent(defaultProps); - wrapper.vm.selectQuery('9'); - wrapper.vm.selectQuery('>'); - wrapper.setData({ - threshold: 800, - }); + modal().vm.$emit('shown'); + + findThresholdField().vm.$emit('input', 800); + findRunbookField().vm.$emit('input', INVALID_URL); modal().vm.$emit('hidden'); @@ -117,6 +119,7 @@ describe('AlertWidgetForm', () => { expect(wrapper.vm.operator).toBe(null); expect(wrapper.vm.threshold).toBe(null); expect(wrapper.vm.prometheusMetricId).toBe(null); + expect(wrapper.vm.runbookUrl).toBe(null); }); it('sets selectedAlert to the provided configuredAlert on modal show', () => { @@ -163,7 +166,7 @@ describe('AlertWidgetForm', () => { beforeEach(() => { createComponent(propsWithAlertData); - wrapper.vm.selectQuery(metricId); + modal().vm.$emit('shown'); }); it('sets tracking options for delete alert', () => { @@ -176,7 +179,7 @@ describe('AlertWidgetForm', () => { }); it('emits "delete" event when form values unchanged', () => { - wrapper.vm.handleSubmit(e); + modal().vm.$emit('ok', stubEvent); expect(wrapper.emitted().delete[0]).toEqual([ { @@ -184,37 +187,52 @@ describe('AlertWidgetForm', () => { operator: '<', threshold: 5, prometheus_metric_id: '8', + runbookUrl: INVALID_URL, }, ]); - expect(e.preventDefault).toHaveBeenCalledTimes(1); }); + }); - it('emits "update" event when form changed', () => { - wrapper.setData({ - threshold: 11, - }); + it('emits "update" event when form changed', () => { + const updatedRunbookUrl = `${INVALID_URL}/test`; - wrapper.vm.handleSubmit(e); + createComponent(propsWithAlertData); - expect(wrapper.emitted().update[0]).toEqual([ - { - alert: 'alert', - operator: '<', - threshold: 11, - prometheus_metric_id: '8', - }, - ]); - expect(e.preventDefault).toHaveBeenCalledTimes(1); - }); + modal().vm.$emit('shown'); + + findRunbookField().vm.$emit('input', updatedRunbookUrl); + findThresholdField().vm.$emit('input', 11); - it('sets tracking options for update alert', () => { - wrapper.setData({ + modal().vm.$emit('ok', stubEvent); + + expect(wrapper.emitted().update[0]).toEqual([ + { + alert: 'alert', + operator: '<', threshold: 11, - }); + prometheus_metric_id: '8', + runbookUrl: updatedRunbookUrl, + }, + ]); + }); + + it('sets tracking options for update alert', async () => { + createComponent(propsWithAlertData); + + modal().vm.$emit('shown'); + + findThresholdField().vm.$emit('input', 11); + + await wrapper.vm.$nextTick(); + + expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update); + }); + + describe('alert runbooks', () => { + it('shows the runbook field', () => { + createComponent(); - return wrapper.vm.$nextTick(() => { - expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update); - }); + expect(findRunbookField().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/monitoring/components/charts/gauge_spec.js b/spec/frontend/monitoring/components/charts/gauge_spec.js new file mode 100644 index 00000000000..850e2ca87db --- /dev/null +++ b/spec/frontend/monitoring/components/charts/gauge_spec.js @@ -0,0 +1,215 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlGaugeChart } from '@gitlab/ui/dist/charts'; +import GaugeChart from '~/monitoring/components/charts/gauge.vue'; +import { gaugeChartGraphData } from '../../graph_data'; + +describe('Gauge Chart component', () => { + const defaultGraphData = gaugeChartGraphData(); + + let wrapper; + + const findGaugeChart = () => wrapper.find(GlGaugeChart); + + const createWrapper = ({ ...graphProps } = {}) => { + wrapper = shallowMount(GaugeChart, { + propsData: { + graphData: { + ...defaultGraphData, + ...graphProps, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('chart component', () => { + it('is rendered when props are passed', () => { + createWrapper(); + + expect(findGaugeChart().exists()).toBe(true); + }); + }); + + describe('min and max', () => { + const MIN_DEFAULT = 0; + const MAX_DEFAULT = 100; + + it('are passed to chart component', () => { + createWrapper(); + + expect(findGaugeChart().props('min')).toBe(100); + expect(findGaugeChart().props('max')).toBe(1000); + }); + + const invalidCases = [undefined, NaN, 'a string']; + + it.each(invalidCases)( + 'if min has invalid value, defaults are used for both min and max', + invalidValue => { + createWrapper({ minValue: invalidValue }); + + expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT); + expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT); + }, + ); + + it.each(invalidCases)( + 'if max has invalid value, defaults are used for both min and max', + invalidValue => { + createWrapper({ minValue: invalidValue }); + + expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT); + expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT); + }, + ); + + it('if min is bigger than max, defaults are used for both min and max', () => { + createWrapper({ minValue: 100, maxValue: 0 }); + + expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT); + expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT); + }); + }); + + describe('thresholds', () => { + it('thresholds are set on chart', () => { + createWrapper(); + + expect(findGaugeChart().props('thresholds')).toEqual([500, 800]); + }); + + it('when no thresholds are defined, a default threshold is defined at 95% of max_value', () => { + createWrapper({ + minValue: 0, + maxValue: 100, + thresholds: {}, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([95]); + }); + + it('when out of min-max bounds thresholds are defined, a default threshold is defined at 95% of the range between min_value and max_value', () => { + createWrapper({ + thresholds: { + values: [-10, 1500], + }, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([855]); + }); + + describe('when mode is absolute', () => { + it('only valid threshold values are used', () => { + createWrapper({ + thresholds: { + mode: 'absolute', + values: [undefined, 10, 110, NaN, 'a string', 400], + }, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([110, 400]); + }); + + it('if all threshold values are invalid, a default threshold is defined at 95% of the range between min_value and max_value', () => { + createWrapper({ + thresholds: { + mode: 'absolute', + values: [NaN, undefined, 'a string', 1500], + }, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([855]); + }); + }); + + describe('when mode is percentage', () => { + it('when values outside of 0-100 bounds are used, a default threshold is defined at 95% of max_value', () => { + createWrapper({ + thresholds: { + mode: 'percentage', + values: [110], + }, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([855]); + }); + + it('if all threshold values are invalid, a default threshold is defined at 95% of max_value', () => { + createWrapper({ + thresholds: { + mode: 'percentage', + values: [NaN, undefined, 'a string', 1500], + }, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([855]); + }); + }); + }); + + describe('split (the number of ticks on the chart arc)', () => { + const SPLIT_DEFAULT = 10; + + it('is passed to chart as prop', () => { + createWrapper(); + + expect(findGaugeChart().props('splitNumber')).toBe(20); + }); + + it('if not explicitly set, passes a default value to chart', () => { + createWrapper({ split: '' }); + + expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT); + }); + + it('if set as a number that is not an integer, passes the default value to chart', () => { + createWrapper({ split: 10.5 }); + + expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT); + }); + + it('if set as a negative number, passes the default value to chart', () => { + createWrapper({ split: -10 }); + + expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT); + }); + }); + + describe('text (the text displayed on the gauge for the current value)', () => { + it('displays the query result value when format is not set', () => { + createWrapper({ format: '' }); + + expect(findGaugeChart().props('text')).toBe('3'); + }); + + it('displays the query result value when format is set to invalid value', () => { + createWrapper({ format: 'invalid' }); + + expect(findGaugeChart().props('text')).toBe('3'); + }); + + it('displays a formatted query result value when format is set', () => { + createWrapper(); + + expect(findGaugeChart().props('text')).toBe('3kB'); + }); + + it('displays a placeholder value when metric is empty', () => { + createWrapper({ metrics: [] }); + + expect(findGaugeChart().props('text')).toBe('--'); + }); + }); + + describe('value', () => { + it('correct value is passed', () => { + createWrapper(); + + expect(findGaugeChart().props('value')).toBe(3); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/charts/heatmap_spec.js b/spec/frontend/monitoring/components/charts/heatmap_spec.js index 2a1c78025ae..27a2021e9be 100644 --- a/spec/frontend/monitoring/components/charts/heatmap_spec.js +++ b/spec/frontend/monitoring/components/charts/heatmap_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlHeatmap } from '@gitlab/ui/dist/charts'; import timezoneMock from 'timezone-mock'; import Heatmap from '~/monitoring/components/charts/heatmap.vue'; -import { graphDataPrometheusQueryRangeMultiTrack } from '../../mock_data'; +import { heatmapGraphData } from '../../graph_data'; describe('Heatmap component', () => { let wrapper; @@ -10,10 +10,12 @@ describe('Heatmap component', () => { const findChart = () => wrapper.find(GlHeatmap); + const graphData = heatmapGraphData(); + const createWrapper = (props = {}) => { wrapper = shallowMount(Heatmap, { propsData: { - graphData: graphDataPrometheusQueryRangeMultiTrack, + graphData: heatmapGraphData(), containerWidth: 100, ...props, }, @@ -38,11 +40,11 @@ describe('Heatmap component', () => { }); it('should display a label on the x axis', () => { - expect(wrapper.vm.xAxisName).toBe(graphDataPrometheusQueryRangeMultiTrack.x_label); + expect(wrapper.vm.xAxisName).toBe(graphData.xLabel); }); it('should display a label on the y axis', () => { - expect(wrapper.vm.yAxisName).toBe(graphDataPrometheusQueryRangeMultiTrack.y_label); + expect(wrapper.vm.yAxisName).toBe(graphData.y_label); }); // According to the echarts docs https://echarts.apache.org/en/option.html#series-heatmap.data @@ -54,24 +56,24 @@ describe('Heatmap component', () => { const row = wrapper.vm.chartData[0]; expect(row.length).toBe(3); - expect(wrapper.vm.chartData.length).toBe(30); + expect(wrapper.vm.chartData.length).toBe(6); }); it('returns a series of labels for the x axis', () => { const { xAxisLabels } = wrapper.vm; - expect(xAxisLabels.length).toBe(5); + expect(xAxisLabels.length).toBe(2); }); describe('y axis labels', () => { - const gmtLabels = ['3:00 PM', '4:00 PM', '5:00 PM', '6:00 PM', '7:00 PM', '8:00 PM']; + const gmtLabels = ['8:10 PM', '8:12 PM', '8:14 PM']; it('y-axis labels are formatted in AM/PM format', () => { expect(findChart().props('yAxisLabels')).toEqual(gmtLabels); }); describe('when in PT timezone', () => { - const ptLabels = ['8:00 AM', '9:00 AM', '10:00 AM', '11:00 AM', '12:00 PM', '1:00 PM']; + const ptLabels = ['1:10 PM', '1:12 PM', '1:14 PM']; const utcLabels = gmtLabels; // Identical in this case beforeAll(() => { diff --git a/spec/frontend/monitoring/components/charts/options_spec.js b/spec/frontend/monitoring/components/charts/options_spec.js index 1c8fdc01e3e..3372d27e4f9 100644 --- a/spec/frontend/monitoring/components/charts/options_spec.js +++ b/spec/frontend/monitoring/components/charts/options_spec.js @@ -1,5 +1,9 @@ import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; -import { getYAxisOptions, getTooltipFormatter } from '~/monitoring/components/charts/options'; +import { + getYAxisOptions, + getTooltipFormatter, + getValidThresholds, +} from '~/monitoring/components/charts/options'; describe('options spec', () => { describe('getYAxisOptions', () => { @@ -82,4 +86,242 @@ describe('options spec', () => { expect(formatter(1)).toBe('1.000B'); }); }); + + describe('getValidThresholds', () => { + const invalidCases = [null, undefined, NaN, 'a string', true, false]; + + let thresholds; + + afterEach(() => { + thresholds = null; + }); + + it('returns same thresholds when passed values within range', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [10, 50], + }); + + expect(thresholds).toEqual([10, 50]); + }); + + it('filters out thresholds that are out of range', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [-5, 10, 110], + }); + + expect(thresholds).toEqual([10]); + }); + it('filters out duplicate thresholds', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [5, 5, 10, 10], + }); + + expect(thresholds).toEqual([5, 10]); + }); + + it('sorts passed thresholds and applies only the first two in ascending order', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [10, 1, 35, 20, 5], + }); + + expect(thresholds).toEqual([1, 5]); + }); + + it('thresholds equal to min or max are filtered out', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [0, 100], + }); + + expect(thresholds).toEqual([]); + }); + + it.each(invalidCases)('invalid values for thresholds are filtered out', invalidValue => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [10, invalidValue], + }); + + expect(thresholds).toEqual([10]); + }); + + describe('range', () => { + it('when range is not defined, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }); + + it('when min is not defined, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { max: 100 }, + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }); + + it('when max is not defined, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0 }, + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }); + + it('when min is larger than max, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 100, max: 0 }, + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }); + + it.each(invalidCases)( + 'when min has invalid value, empty result is returned', + invalidValue => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: invalidValue, max: 100 }, + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }, + ); + + it.each(invalidCases)( + 'when max has invalid value, empty result is returned', + invalidValue => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: invalidValue }, + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }, + ); + }); + + describe('values', () => { + it('if values parameter is omitted, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + }); + + expect(thresholds).toEqual([]); + }); + + it('if there are no values passed, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [], + }); + + expect(thresholds).toEqual([]); + }); + + it.each(invalidCases)( + 'if invalid values are passed, empty result is returned', + invalidValue => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [invalidValue], + }); + + expect(thresholds).toEqual([]); + }, + ); + }); + + describe('mode', () => { + it.each(invalidCases)( + 'if invalid values are passed, empty result is returned', + invalidValue => { + thresholds = getValidThresholds({ + mode: invalidValue, + range: { min: 0, max: 100 }, + values: [10, 50], + }); + + expect(thresholds).toEqual([]); + }, + ); + + it('if mode is not passed, empty result is returned', () => { + thresholds = getValidThresholds({ + range: { min: 0, max: 100 }, + values: [10, 50], + }); + + expect(thresholds).toEqual([]); + }); + + describe('absolute mode', () => { + it('absolute mode behaves correctly', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [10, 50], + }); + + expect(thresholds).toEqual([10, 50]); + }); + }); + + describe('percentage mode', () => { + it('percentage mode behaves correctly', () => { + thresholds = getValidThresholds({ + mode: 'percentage', + range: { min: 0, max: 1000 }, + values: [10, 50], + }); + + expect(thresholds).toEqual([100, 500]); + }); + + const outOfPercentBoundsValues = [-1, 0, 100, 101]; + it.each(outOfPercentBoundsValues)( + 'when values out of 0-100 range are passed, empty result is returned', + invalidValue => { + thresholds = getValidThresholds({ + mode: 'percentage', + range: { min: 0, max: 1000 }, + values: [invalidValue], + }); + + expect(thresholds).toEqual([]); + }, + ); + }); + }); + + it('calling without passing object parameter returns empty array', () => { + thresholds = getValidThresholds(); + + expect(thresholds).toEqual([]); + }); + }); }); diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js index 3783b1eebd2..37712eb3012 100644 --- a/spec/frontend/monitoring/components/charts/single_stat_spec.js +++ b/spec/frontend/monitoring/components/charts/single_stat_spec.js @@ -1,71 +1,91 @@ import { shallowMount } from '@vue/test-utils'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; import SingleStatChart from '~/monitoring/components/charts/single_stat.vue'; import { singleStatGraphData } from '../../graph_data'; describe('Single Stat Chart component', () => { - let singleStatChart; + let wrapper; - beforeEach(() => { - singleStatChart = shallowMount(SingleStatChart, { + const createComponent = (props = {}) => { + wrapper = shallowMount(SingleStatChart, { propsData: { graphData: singleStatGraphData({}, { unit: 'MB' }), + ...props, }, }); + }; + + const findChart = () => wrapper.find(GlSingleStat); + + beforeEach(() => { + createComponent(); }); afterEach(() => { - singleStatChart.destroy(); + wrapper.destroy(); }); describe('computed', () => { describe('statValue', () => { it('should interpolate the value and unit props', () => { - expect(singleStatChart.vm.statValue).toBe('1.00MB'); + expect(findChart().props('value')).toBe('1.00MB'); }); it('should change the value representation to a percentile one', () => { - singleStatChart.setProps({ + createComponent({ graphData: singleStatGraphData({ max_value: 120 }, { value: 91 }), }); - expect(singleStatChart.vm.statValue).toContain('75.83%'); + expect(findChart().props('value')).toContain('75.83%'); }); it('should display NaN for non numeric maxValue values', () => { - singleStatChart.setProps({ + createComponent({ graphData: singleStatGraphData({ max_value: 'not a number' }), }); - expect(singleStatChart.vm.statValue).toContain('NaN'); + expect(findChart().props('value')).toContain('NaN'); }); it('should display NaN for missing query values', () => { - singleStatChart.setProps({ + createComponent({ graphData: singleStatGraphData({ max_value: 120 }, { value: 'NaN' }), }); - expect(singleStatChart.vm.statValue).toContain('NaN'); + expect(findChart().props('value')).toContain('NaN'); + }); + + it('should not display `unit` when `unit` is undefined', () => { + createComponent({ + graphData: singleStatGraphData({}, { unit: undefined }), + }); + + expect(findChart().props('value')).not.toContain('undefined'); }); - describe('field attribute', () => { + it('should not display `unit` when `unit` is null', () => { + createComponent({ + graphData: singleStatGraphData({}, { unit: null }), + }); + + expect(findChart().props('value')).not.toContain('null'); + }); + + describe('when a field attribute is set', () => { it('displays a label value instead of metric value when field attribute is used', () => { - singleStatChart.setProps({ + createComponent({ graphData: singleStatGraphData({ field: 'job' }, { isVector: true }), }); - return singleStatChart.vm.$nextTick(() => { - expect(singleStatChart.vm.statValue).toContain('prometheus'); - }); + expect(findChart().props('value')).toContain('prometheus'); }); it('displays No data to display if field attribute is not present', () => { - singleStatChart.setProps({ + createComponent({ graphData: singleStatGraphData({ field: 'this-does-not-exist' }), }); - return singleStatChart.vm.$nextTick(() => { - expect(singleStatChart.vm.statValue).toContain('No data to display'); - }); + expect(findChart().props('value')).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 97386be9e32..6f9a89feb3e 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -12,7 +12,12 @@ import { import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper'; import { panelTypes, chartHeight } from '~/monitoring/constants'; import TimeSeries from '~/monitoring/components/charts/time_series.vue'; -import { deploymentData, mockProjectDir, annotationsData } from '../../mock_data'; +import { + deploymentData, + mockProjectDir, + annotationsData, + mockFixedTimeRange, +} from '../../mock_data'; import { timeSeriesGraphData } from '../../graph_data'; @@ -42,6 +47,7 @@ describe('Time series component', () => { deploymentData, annotations: annotationsData, projectPath: `${TEST_HOST}${mockProjectDir}`, + timeRange: mockFixedTimeRange, ...props, }, stubs: { @@ -382,6 +388,25 @@ describe('Time series component', () => { }); describe('chartOptions', () => { + describe('x-Axis bounds', () => { + it('is set to the time range bounds', () => { + expect(getChartOptions().xAxis).toMatchObject({ + min: mockFixedTimeRange.start, + max: mockFixedTimeRange.end, + }); + }); + + it('is not set if time range is not set or incorrectly set', () => { + wrapper.setProps({ + timeRange: {}, + }); + return wrapper.vm.$nextTick(() => { + expect(getChartOptions().xAxis).not.toHaveProperty('min'); + expect(getChartOptions().xAxis).not.toHaveProperty('max'); + }); + }); + }); + describe('dataZoom', () => { it('renders with scroll handle icons', () => { expect(getChartOptions().dataZoom).toHaveLength(1); diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js new file mode 100644 index 00000000000..024b2cbd7f1 --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js @@ -0,0 +1,440 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlNewDropdownItem } from '@gitlab/ui'; +import { createStore } from '~/monitoring/stores'; +import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from '~/monitoring/router/constants'; +import { setupAllDashboards, setupStoreWithData } from '../store_utils'; +import { redirectTo } from '~/lib/utils/url_utility'; +import Tracking from '~/tracking'; +import ActionsMenu from '~/monitoring/components/dashboard_actions_menu.vue'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; +import { dashboardActionsMenuProps, dashboardGitResponse } from '../mock_data'; +import * as types from '~/monitoring/stores/mutation_types'; + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), + queryToObject: jest.fn(), +})); + +describe('Actions menu', () => { + const ootbDashboards = [dashboardGitResponse[0], dashboardGitResponse[2]]; + const customDashboard = dashboardGitResponse[1]; + + let store; + let wrapper; + + const findAddMetricItem = () => wrapper.find('[data-testid="add-metric-item"]'); + const findAddPanelItemEnabled = () => wrapper.find('[data-testid="add-panel-item-enabled"]'); + const findAddPanelItemDisabled = () => wrapper.find('[data-testid="add-panel-item-disabled"]'); + const findAddMetricModal = () => wrapper.find('[data-testid="add-metric-modal"]'); + const findAddMetricModalSubmitButton = () => + wrapper.find('[data-testid="add-metric-modal-submit-button"]'); + const findStarDashboardItem = () => wrapper.find('[data-testid="star-dashboard-item"]'); + const findEditDashboardItemEnabled = () => + wrapper.find('[data-testid="edit-dashboard-item-enabled"]'); + const findEditDashboardItemDisabled = () => + wrapper.find('[data-testid="edit-dashboard-item-disabled"]'); + const findDuplicateDashboardItem = () => wrapper.find('[data-testid="duplicate-dashboard-item"]'); + const findDuplicateDashboardModal = () => + wrapper.find('[data-testid="duplicate-dashboard-modal"]'); + const findCreateDashboardItem = () => wrapper.find('[data-testid="create-dashboard-item"]'); + const findCreateDashboardModal = () => wrapper.find('[data-testid="create-dashboard-modal"]'); + + const createShallowWrapper = (props = {}, options = {}) => { + wrapper = shallowMount(ActionsMenu, { + propsData: { ...dashboardActionsMenuProps, ...props }, + store, + ...options, + }); + }; + + beforeEach(() => { + store = createStore(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('add metric item', () => { + it('is rendered when custom metrics are available', () => { + createShallowWrapper(); + + return wrapper.vm.$nextTick(() => { + expect(findAddMetricItem().exists()).toBe(true); + }); + }); + + it('is not rendered when custom metrics are not available', () => { + createShallowWrapper({ + addingMetricsAvailable: false, + }); + + return wrapper.vm.$nextTick(() => { + expect(findAddMetricItem().exists()).toBe(false); + }); + }); + + describe('when available', () => { + beforeEach(() => { + createShallowWrapper(); + }); + + it('modal for custom metrics form is rendered', () => { + expect(findAddMetricModal().exists()).toBe(true); + expect(findAddMetricModal().attributes().modalid).toBe('addMetric'); + }); + + it('add metric modal submit button exists', () => { + expect(findAddMetricModalSubmitButton().exists()).toBe(true); + }); + + it('renders custom metrics form fields', () => { + expect(wrapper.find(CustomMetricsFormFields).exists()).toBe(true); + }); + }); + + describe('when not available', () => { + beforeEach(() => { + createShallowWrapper({ addingMetricsAvailable: false }); + }); + + it('modal for custom metrics form is not rendered', () => { + expect(findAddMetricModal().exists()).toBe(false); + }); + }); + + describe('adding new metric from modal', () => { + let origPage; + + beforeEach(done => { + jest.spyOn(Tracking, 'event').mockReturnValue(); + createShallowWrapper(); + + setupStoreWithData(store); + + origPage = document.body.dataset.page; + document.body.dataset.page = 'projects:environments:metrics'; + + wrapper.vm.$nextTick(done); + }); + + afterEach(() => { + document.body.dataset.page = origPage; + }); + + it('is tracked', done => { + const submitButton = findAddMetricModalSubmitButton().vm; + + wrapper.vm.$nextTick(() => { + submitButton.$el.click(); + wrapper.vm.$nextTick(() => { + expect(Tracking.event).toHaveBeenCalledWith( + document.body.dataset.page, + 'click_button', + { + label: 'add_new_metric', + property: 'modal', + value: undefined, + }, + ); + done(); + }); + }); + }); + }); + }); + + describe('add panel item', () => { + const GlNewDropdownItemStub = { + extends: GlNewDropdownItem, + props: { + to: [String, Object], + }, + }; + + let $route; + + beforeEach(() => { + $route = { name: DASHBOARD_PAGE, params: { dashboard: 'my_dashboard.yml' } }; + + createShallowWrapper( + { + isOotbDashboard: false, + }, + { + mocks: { $route }, + stubs: { GlNewDropdownItem: GlNewDropdownItemStub }, + }, + ); + }); + + it('is disabled for ootb dashboards', () => { + createShallowWrapper({ + isOotbDashboard: true, + }); + + return wrapper.vm.$nextTick(() => { + expect(findAddPanelItemDisabled().exists()).toBe(true); + }); + }); + + it('is visible for custom dashboards', () => { + expect(findAddPanelItemEnabled().exists()).toBe(true); + }); + + it('renders a link to the new panel page for custom dashboards', () => { + expect(findAddPanelItemEnabled().props('to')).toEqual({ + name: PANEL_NEW_PAGE, + params: { + dashboard: 'my_dashboard.yml', + }, + }); + }); + }); + + describe('edit dashboard yml item', () => { + beforeEach(() => { + createShallowWrapper(); + }); + + describe('when current dashboard is custom', () => { + beforeEach(() => { + setupAllDashboards(store, customDashboard.path); + }); + + it('enabled item is rendered and has falsy disabled attribute', () => { + expect(findEditDashboardItemEnabled().exists()).toBe(true); + expect(findEditDashboardItemEnabled().attributes('disabled')).toBe(undefined); + }); + + it('enabled item links to their edit path', () => { + expect(findEditDashboardItemEnabled().attributes('href')).toBe( + customDashboard.project_blob_path, + ); + }); + + it('disabled item is not rendered', () => { + expect(findEditDashboardItemDisabled().exists()).toBe(false); + }); + }); + + describe.each(ootbDashboards)('when current dashboard is OOTB', dashboard => { + beforeEach(() => { + setupAllDashboards(store, dashboard.path); + }); + + it('disabled item is rendered and has disabled attribute set on it', () => { + expect(findEditDashboardItemDisabled().exists()).toBe(true); + expect(findEditDashboardItemDisabled().attributes('disabled')).toBe(''); + }); + + it('enabled item is not rendered', () => { + expect(findEditDashboardItemEnabled().exists()).toBe(false); + }); + }); + }); + + describe('duplicate dashboard item', () => { + beforeEach(() => { + createShallowWrapper(); + }); + + describe.each(ootbDashboards)('when current dashboard is OOTB', dashboard => { + beforeEach(() => { + setupAllDashboards(store, dashboard.path); + }); + + it('is rendered', () => { + expect(findDuplicateDashboardItem().exists()).toBe(true); + }); + + it('duplicate dashboard modal is rendered', () => { + expect(findDuplicateDashboardModal().exists()).toBe(true); + }); + + it('clicking on item opens up the duplicate dashboard modal', () => { + const modalId = 'duplicateDashboard'; + const modalTrigger = findDuplicateDashboardItem(); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + + modalTrigger.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + }); + }); + }); + + describe('when current dashboard is custom', () => { + beforeEach(() => { + setupAllDashboards(store, customDashboard.path); + }); + + it('is not rendered', () => { + expect(findDuplicateDashboardItem().exists()).toBe(false); + }); + + it('duplicate dashboard modal is not rendered', () => { + expect(findDuplicateDashboardModal().exists()).toBe(false); + }); + }); + + describe('when no dashboard is set', () => { + it('is not rendered', () => { + expect(findDuplicateDashboardItem().exists()).toBe(false); + }); + + it('duplicate dashboard modal is not rendered', () => { + expect(findDuplicateDashboardModal().exists()).toBe(false); + }); + }); + + describe('when a dashboard has been duplicated in the duplicate dashboard modal', () => { + beforeEach(() => { + store.state.monitoringDashboard.projectPath = 'root/sandbox'; + + setupAllDashboards(store, dashboardGitResponse[0].path); + }); + + it('redirects to the newly created dashboard', () => { + delete window.location; + window.location = new URL('https://localhost'); + + const newDashboard = dashboardGitResponse[1]; + + 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('star dashboard item', () => { + beforeEach(() => { + createShallowWrapper(); + setupAllDashboards(store); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + + it('is shown', () => { + expect(findStarDashboardItem().exists()).toBe(true); + }); + + it('is not disabled', () => { + expect(findStarDashboardItem().attributes('disabled')).toBeFalsy(); + }); + + it('is disabled when starring is taking place', () => { + store.commit(`monitoringDashboard/${types.REQUEST_DASHBOARD_STARRING}`); + + return wrapper.vm.$nextTick(() => { + expect(findStarDashboardItem().exists()).toBe(true); + expect(findStarDashboardItem().attributes('disabled')).toBe('true'); + }); + }); + + it('on click it dispatches a toggle star action', () => { + findStarDashboardItem().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/toggleStarredValue', + undefined, + ); + }); + }); + + describe('when dashboard is not starred', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: dashboardGitResponse[0].path, + }); + return wrapper.vm.$nextTick(); + }); + + it('item text shows "Star dashboard"', () => { + expect(findStarDashboardItem().html()).toMatch(/Star dashboard/); + }); + }); + + describe('when dashboard is starred', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: dashboardGitResponse[1].path, + }); + return wrapper.vm.$nextTick(); + }); + + it('item text shows "Unstar dashboard"', () => { + expect(findStarDashboardItem().html()).toMatch(/Unstar dashboard/); + }); + }); + }); + + describe('create dashboard item', () => { + beforeEach(() => { + createShallowWrapper(); + }); + + it('is rendered by default but it is disabled', () => { + expect(findCreateDashboardItem().attributes('disabled')).toBe('true'); + }); + + describe('when project path is set', () => { + const mockProjectPath = 'root/sandbox'; + const mockAddDashboardDocPath = '/doc/add-dashboard'; + + beforeEach(() => { + store.state.monitoringDashboard.projectPath = mockProjectPath; + store.state.monitoringDashboard.addDashboardDocumentationPath = mockAddDashboardDocPath; + }); + + it('is not disabled', () => { + expect(findCreateDashboardItem().attributes('disabled')).toBe(undefined); + }); + + it('renders a modal for creating a dashboard', () => { + expect(findCreateDashboardModal().exists()).toBe(true); + }); + + it('clicking opens up the modal', () => { + const modalId = 'createDashboard'; + const modalTrigger = findCreateDashboardItem(); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + + modalTrigger.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + }); + }); + + it('modal gets passed correct props', () => { + expect(findCreateDashboardModal().props('projectPath')).toBe(mockProjectPath); + expect(findCreateDashboardModal().props('addDashboardDocumentationPath')).toBe( + mockAddDashboardDocPath, + ); + }); + }); + + describe('when project path is not set', () => { + beforeEach(() => { + store.state.monitoringDashboard.projectPath = null; + }); + + it('is disabled', () => { + expect(findCreateDashboardItem().attributes('disabled')).toBe('true'); + }); + + it('does not render a modal for creating a dashboard', () => { + expect(findCreateDashboardModal().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboard_header_spec.js b/spec/frontend/monitoring/components/dashboard_header_spec.js index 5a1a615c703..5cf24706ebd 100644 --- a/spec/frontend/monitoring/components/dashboard_header_spec.js +++ b/spec/frontend/monitoring/components/dashboard_header_spec.js @@ -1,16 +1,23 @@ import { shallowMount } from '@vue/test-utils'; +import { GlNewDropdownItem, GlSearchBoxByType, GlLoadingIcon, GlButton } from '@gitlab/ui'; import { createStore } from '~/monitoring/stores'; +import * as types from '~/monitoring/stores/mutation_types'; +import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; +import RefreshButton from '~/monitoring/components/refresh_button.vue'; 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 DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; +import ActionsMenu from '~/monitoring/components/dashboard_actions_menu.vue'; +import { setupAllDashboards, setupStoreWithDashboard, setupStoreWithData } from '../store_utils'; import { + environmentData, dashboardGitResponse, selfMonitoringDashboardGitResponse, dashboardHeaderProps, } from '../mock_data'; import { redirectTo } from '~/lib/utils/url_utility'; +const mockProjectPath = 'https://path/to/project'; + jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), queryToObject: jest.fn(), @@ -21,13 +28,22 @@ 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 findDashboardDropdown = () => wrapper.find(DashboardsDropdown); + + const findEnvsDropdown = () => wrapper.find({ ref: 'monitorEnvironmentsDropdown' }); + const findEnvsDropdownItems = () => findEnvsDropdown().findAll(GlNewDropdownItem); + const findEnvsDropdownSearch = () => findEnvsDropdown().find(GlSearchBoxByType); + const findEnvsDropdownSearchMsg = () => wrapper.find({ ref: 'monitorEnvironmentsDropdownMsg' }); + const findEnvsDropdownLoadingIcon = () => findEnvsDropdown().find(GlLoadingIcon); + + const findDateTimePicker = () => wrapper.find(DateTimePicker); + const findRefreshButton = () => wrapper.find(RefreshButton); + + const findActionsMenu = () => wrapper.find(ActionsMenu); + + const setSearchTerm = searchTerm => { + store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm); + }; const createShallowWrapper = (props = {}, options = {}) => { wrapper = shallowMount(DashboardHeader, { @@ -45,139 +61,315 @@ describe('Dashboard header', () => { wrapper.destroy(); }); - describe('when a dashboard has been duplicated in the duplicate dashboard modal', () => { + describe('dashboards dropdown', () => { beforeEach(() => { - store.state.monitoringDashboard.projectPath = 'root/sandbox'; + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + projectPath: mockProjectPath, + }); + + createShallowWrapper(); }); - /** - * 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]; + it('shows the dashboard dropdown', () => { + expect(findDashboardDropdown().exists()).toBe(true); + }); - createShallowWrapper(); + it('when an out of the box dashboard is selected, encodes dashboard path', () => { + findDashboardDropdown().vm.$emit('selectDashboard', { + path: '.gitlab/dashboards/dashboard©.yml', + out_of_the_box_dashboard: true, + display_name: 'A display name', + }); - const newDashboardUrl = 'root/sandbox/-/metrics/dashboard.yml'; - findDuplicateDashboardModal().vm.$emit('dashboardDuplicated', newDashboard); + expect(redirectTo).toHaveBeenCalledWith( + `${mockProjectPath}/-/metrics/.gitlab%2Fdashboards%2Fdashboard%26copy.yml`, + ); + }); - return wrapper.vm.$nextTick().then(() => { - expect(redirectTo).toHaveBeenCalled(); - expect(redirectTo).toHaveBeenCalledWith(newDashboardUrl); + it('when a custom dashboard is selected, encodes dashboard display name', () => { + findDashboardDropdown().vm.$emit('selectDashboard', { + path: '.gitlab/dashboards/file&path.yml', + display_name: 'dashboard©.yml', }); + + expect(redirectTo).toHaveBeenCalledWith(`${mockProjectPath}/-/metrics/dashboard%26copy.yml`); }); }); - describe('actions menu', () => { + describe('environments dropdown', () => { beforeEach(() => { - store.state.monitoringDashboard.projectPath = ''; createShallowWrapper(); }); - it('is rendered if projectPath is set in store', () => { - store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + it('shows the environments dropdown', () => { + expect(findEnvsDropdown().exists()).toBe(true); + }); - return wrapper.vm.$nextTick().then(() => { - expect(findActionsMenu().exists()).toBe(true); + it('renders a search input', () => { + expect(findEnvsDropdownSearch().exists()).toBe(true); + }); + + describe('when environments data is not loaded', () => { + beforeEach(() => { + setupStoreWithDashboard(store); + return wrapper.vm.$nextTick(); + }); + + it('there are no environments listed', () => { + expect(findEnvsDropdownItems()).toHaveLength(0); + }); + }); + + describe('when environments data is loaded', () => { + const currentDashboard = dashboardGitResponse[0].path; + const currentEnvironmentName = environmentData[0].name; + + beforeEach(() => { + setupStoreWithData(store); + store.state.monitoringDashboard.projectPath = mockProjectPath; + store.state.monitoringDashboard.currentDashboard = currentDashboard; + store.state.monitoringDashboard.currentEnvironmentName = currentEnvironmentName; + + return wrapper.vm.$nextTick(); + }); + + it('renders dropdown items with the environment name', () => { + const path = `${mockProjectPath}/-/metrics/${encodeURIComponent(currentDashboard)}`; + + findEnvsDropdownItems().wrappers.forEach((itemWrapper, index) => { + const { name, id } = environmentData[index]; + const idParam = encodeURIComponent(id); + + expect(itemWrapper.text()).toBe(name); + expect(itemWrapper.attributes('href')).toBe(`${path}?environment=${idParam}`); + }); + }); + + it('environments dropdown items can be checked', () => { + const items = findEnvsDropdownItems(); + const checkItems = findEnvsDropdownItems().filter(item => item.props('isCheckItem')); + + expect(items).toHaveLength(checkItems.length); + }); + + it('checks the currently selected environment', () => { + const selectedItems = findEnvsDropdownItems().filter(item => item.props('isChecked')); + + expect(selectedItems).toHaveLength(1); + expect(selectedItems.at(0).text()).toBe(currentEnvironmentName); + }); + + it('filters rendered dropdown items', () => { + const searchTerm = 'production'; + const resultEnvs = environmentData.filter(({ name }) => name.indexOf(searchTerm) !== -1); + setSearchTerm(searchTerm); + + return wrapper.vm.$nextTick().then(() => { + expect(findEnvsDropdownItems()).toHaveLength(resultEnvs.length); + }); + }); + + it('does not filter dropdown items if search term is empty string', () => { + const searchTerm = ''; + setSearchTerm(searchTerm); + + return wrapper.vm.$nextTick(() => { + expect(findEnvsDropdownItems()).toHaveLength(environmentData.length); + }); + }); + + it("shows error message if search term doesn't match", () => { + const searchTerm = 'does-not-exist'; + setSearchTerm(searchTerm); + + return wrapper.vm.$nextTick(() => { + expect(findEnvsDropdownSearchMsg().isVisible()).toBe(true); + }); + }); + + it('shows loading element when environments fetch is still loading', () => { + store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`); + + return wrapper.vm + .$nextTick() + .then(() => { + expect(findEnvsDropdownLoadingIcon().exists()).toBe(true); + }) + .then(() => { + store.commit( + `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, + environmentData, + ); + }) + .then(() => { + expect(findEnvsDropdownLoadingIcon().exists()).toBe(false); + }); }); }); + }); - it('is not rendered if projectPath is not set in store', () => { - expect(findActionsMenu().exists()).toBe(false); + describe('date time picker', () => { + beforeEach(() => { + createShallowWrapper(); }); - it('contains a modal', () => { - store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + it('is rendered', () => { + expect(findDateTimePicker().exists()).toBe(true); + }); - return wrapper.vm.$nextTick().then(() => { - expect(findActionsMenu().contains(CreateDashboardModal)).toBe(true); + describe('timezone setting', () => { + const setupWithTimezone = value => { + store = createStore({ dashboardTimezone: value }); + createShallowWrapper(); + }; + + describe('local timezone is enabled by default', () => { + it('shows the data time picker in local timezone', () => { + expect(findDateTimePicker().props('utc')).toBe(false); + }); + }); + + describe('when LOCAL timezone is enabled', () => { + beforeEach(() => { + setupWithTimezone('LOCAL'); + }); + + it('shows the data time picker in local timezone', () => { + expect(findDateTimePicker().props('utc')).toBe(false); + }); + }); + + describe('when UTC timezone is enabled', () => { + beforeEach(() => { + setupWithTimezone('UTC'); + }); + + it('shows the data time picker in UTC format', () => { + expect(findDateTimePicker().props('utc')).toBe(true); + }); }); }); + }); + + describe('refresh button', () => { + beforeEach(() => { + createShallowWrapper(); + }); + + it('is rendered', () => { + expect(findRefreshButton().exists()).toBe(true); + }); + }); + + describe('external dashboard link', () => { + beforeEach(() => { + store.state.monitoringDashboard.externalDashboardUrl = '/mockUrl'; + createShallowWrapper(); + + return wrapper.vm.$nextTick(); + }); + + it('shows the link', () => { + const externalDashboardButton = wrapper.find('.js-external-dashboard-link'); + + expect(externalDashboardButton.exists()).toBe(true); + expect(externalDashboardButton.is(GlButton)).toBe(true); + expect(externalDashboardButton.text()).toContain('View full dashboard'); + }); + }); - const duplicableCases = [ - null, // When no path is specified, it uses the default dashboard path. + describe('actions menu', () => { + const ootbDashboards = [ dashboardGitResponse[0].path, - dashboardGitResponse[2].path, selfMonitoringDashboardGitResponse[0].path, ]; + const customDashboards = [ + dashboardGitResponse[1].path, + selfMonitoringDashboardGitResponse[1].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); + it('is rendered', () => { + createShallowWrapper(); - return wrapper.vm.$nextTick().then(() => { - expect(findCreateDashboardMenuItem().exists()).toBe(true); - expect(findCreateDashboardDuplicateItem().exists()).toBe(true); - }); + expect(findActionsMenu().exists()).toBe(true); + }); + + describe('adding metrics prop', () => { + it.each(ootbDashboards)('gets passed true if current dashboard is OOTB', dashboardPath => { + createShallowWrapper({ customMetricsAvailable: true }); + + store.state.monitoringDashboard.emptyState = false; + setupAllDashboards(store, dashboardPath); + + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().props('addingMetricsAvailable')).toBe(true); }); - }, - ); + }); - const nonDuplicableCases = [ - dashboardGitResponse[1].path, - selfMonitoringDashboardGitResponse[1].path, - ]; + it.each(customDashboards)( + 'gets passed false if current dashboard is custom', + dashboardPath => { + createShallowWrapper({ customMetricsAvailable: true }); - 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'; + store.state.monitoringDashboard.emptyState = false; setupAllDashboards(store, dashboardPath); return wrapper.vm.$nextTick().then(() => { - expect(findCreateDashboardMenuItem().exists()).toBe(true); - expect(findCreateDashboardDuplicateItem().exists()).toBe(false); + expect(findActionsMenu().props('addingMetricsAvailable')).toBe(false); }); + }, + ); + + it('gets passed false if empty state is shown', () => { + createShallowWrapper({ customMetricsAvailable: true }); + + store.state.monitoringDashboard.emptyState = true; + setupAllDashboards(store, ootbDashboards[0]); + + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().props('addingMetricsAvailable')).toBe(false); }); - }, - ); - }); + }); - describe('actions menu modals', () => { - const url = 'https://path/to/project'; + it('gets passed false if custom metrics are not available', () => { + createShallowWrapper({ customMetricsAvailable: false }); - beforeEach(() => { - store.state.monitoringDashboard.projectPath = url; - setupAllDashboards(store); + store.state.monitoringDashboard.emptyState = false; + setupAllDashboards(store, ootbDashboards[0]); - createShallowWrapper(); + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().props('addingMetricsAvailable')).toBe(false); + }); + }); }); - it('Clicking on "Create New" opens up a modal', () => { - const modalId = 'createDashboard'; - const modalTrigger = findCreateDashboardMenuItem(); - const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + it('custom metrics path gets passed', () => { + const path = 'https://path/to/customMetrics'; - modalTrigger.trigger('click'); + createShallowWrapper({ customMetricsPath: path }); return wrapper.vm.$nextTick().then(() => { - expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + expect(findActionsMenu().props('customMetricsPath')).toBe(path); }); }); - it('"Create new dashboard" modal contains correct buttons', () => { - expect(findCreateDashboardModal().props('projectPath')).toBe(url); + it('validate query path gets passed', () => { + const path = 'https://path/to/validateQuery'; + + createShallowWrapper({ validateQueryPath: path }); + + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().props('validateQueryPath')).toBe(path); + }); }); - it('"Duplicate Dashboard" opens up a modal', () => { - const modalId = 'duplicateDashboard'; - const modalTrigger = findCreateDashboardDuplicateItem(); - const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + it('default branch gets passed', () => { + const branch = 'branchName'; - modalTrigger.trigger('click'); + createShallowWrapper({ defaultBranch: branch }); return wrapper.vm.$nextTick().then(() => { - expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + expect(findActionsMenu().props('defaultBranch')).toBe(branch); }); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js new file mode 100644 index 00000000000..587ddd23d3f --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js @@ -0,0 +1,234 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlCard, GlForm, GlFormTextarea, GlAlert } from '@gitlab/ui'; +import { createStore } from '~/monitoring/stores'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; +import * as types from '~/monitoring/stores/mutation_types'; +import { metricsDashboardResponse } from '../fixture_data'; +import { mockTimeRange } from '../mock_data'; + +import DashboardPanelBuilder from '~/monitoring/components/dashboard_panel_builder.vue'; +import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; + +const mockPanel = metricsDashboardResponse.dashboard.panel_groups[0].panels[0]; + +describe('dashboard invalid url parameters', () => { + let store; + let wrapper; + let mockShowToast; + + const createComponent = (props = {}, options = {}) => { + wrapper = shallowMount(DashboardPanelBuilder, { + propsData: { ...props }, + store, + stubs: { + GlCard, + }, + mocks: { + $toast: { + show: mockShowToast, + }, + }, + options, + }); + }; + + const findForm = () => wrapper.find(GlForm); + const findTxtArea = () => findForm().find(GlFormTextarea); + const findSubmitBtn = () => findForm().find('[type="submit"]'); + const findClipboardCopyBtn = () => wrapper.find({ ref: 'clipboardCopyBtn' }); + const findViewDocumentationBtn = () => wrapper.find({ ref: 'viewDocumentationBtn' }); + const findOpenRepositoryBtn = () => wrapper.find({ ref: 'openRepositoryBtn' }); + const findPanel = () => wrapper.find(DashboardPanel); + const findTimeRangePicker = () => wrapper.find(DateTimePicker); + const findRefreshButton = () => wrapper.find('[data-testid="previewRefreshButton"]'); + + beforeEach(() => { + mockShowToast = jest.fn(); + store = createStore(); + createComponent(); + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + + afterEach(() => {}); + + it('is mounted', () => { + expect(wrapper.exists()).toBe(true); + }); + + it('displays an empty dashboard panel', () => { + expect(findPanel().exists()).toBe(true); + expect(findPanel().props('graphData')).toBe(null); + }); + + it('does not fetch initial data by default', () => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + describe('yml form', () => { + it('form exists and can be submitted', () => { + expect(findForm().exists()).toBe(true); + expect(findSubmitBtn().exists()).toBe(true); + expect(findSubmitBtn().is('[disabled]')).toBe(false); + }); + + it('form has a text area with a default value', () => { + expect(findTxtArea().exists()).toBe(true); + + const value = findTxtArea().attributes('value'); + + // Panel definition should contain a title and a type + expect(value).toContain('title:'); + expect(value).toContain('type:'); + }); + + it('"copy to clipboard" button works', () => { + findClipboardCopyBtn().vm.$emit('click'); + const clipboardText = findClipboardCopyBtn().attributes('data-clipboard-text'); + + expect(clipboardText).toContain('title:'); + expect(clipboardText).toContain('type:'); + + expect(mockShowToast).toHaveBeenCalledTimes(1); + }); + + it('on submit fetches a panel preview', () => { + findForm().vm.$emit('submit', new Event('submit')); + + return wrapper.vm.$nextTick().then(() => { + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/fetchPanelPreview', + expect.stringContaining('title:'), + ); + }); + }); + + describe('when form is submitted', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.REQUEST_PANEL_PREVIEW}`, 'mock yml content'); + return wrapper.vm.$nextTick(); + }); + + it('submit button is disabled', () => { + expect(findSubmitBtn().is('[disabled]')).toBe(true); + }); + }); + }); + + describe('time range picker', () => { + it('is visible by default', () => { + expect(findTimeRangePicker().exists()).toBe(true); + }); + + it('when changed does not trigger data fetch unless preview panel button is clicked', () => { + // mimic initial state where SET_PANEL_PREVIEW_IS_SHOWN is set to false + store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_IS_SHOWN}`, false); + + return wrapper.vm.$nextTick(() => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + + it('when changed triggers data fetch if preview panel button is clicked', () => { + findForm().vm.$emit('submit', new Event('submit')); + + store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_TIME_RANGE}`, mockTimeRange); + + return wrapper.vm.$nextTick(() => { + expect(store.dispatch).toHaveBeenCalled(); + }); + }); + }); + + describe('refresh', () => { + it('is visible by default', () => { + expect(findRefreshButton().exists()).toBe(true); + }); + + it('when clicked does not trigger data fetch unless preview panel button is clicked', () => { + // mimic initial state where SET_PANEL_PREVIEW_IS_SHOWN is set to false + store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_IS_SHOWN}`, false); + + return wrapper.vm.$nextTick(() => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + + it('when clicked triggers data fetch if preview panel button is clicked', () => { + // mimic state where preview is visible. SET_PANEL_PREVIEW_IS_SHOWN is set to true + store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_IS_SHOWN}`, true); + + findRefreshButton().vm.$emit('click'); + + return wrapper.vm.$nextTick(() => { + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/fetchPanelPreviewMetrics', + undefined, + ); + }); + }); + }); + + describe('instructions card', () => { + const mockDocsPath = '/docs-path'; + const mockProjectPath = '/project-path'; + + beforeEach(() => { + store.state.monitoringDashboard.addDashboardDocumentationPath = mockDocsPath; + store.state.monitoringDashboard.projectPath = mockProjectPath; + + createComponent(); + }); + + it('displays next actions for the user', () => { + expect(findViewDocumentationBtn().exists()).toBe(true); + expect(findViewDocumentationBtn().attributes('href')).toBe(mockDocsPath); + + expect(findOpenRepositoryBtn().exists()).toBe(true); + expect(findOpenRepositoryBtn().attributes('href')).toBe(mockProjectPath); + }); + }); + + describe('when there is an error', () => { + const mockError = 'an error ocurred!'; + + beforeEach(() => { + store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_FAILURE}`, mockError); + return wrapper.vm.$nextTick(); + }); + + it('displays an alert', () => { + expect(wrapper.find(GlAlert).exists()).toBe(true); + expect(wrapper.find(GlAlert).text()).toBe(mockError); + }); + + it('displays an empty dashboard panel', () => { + expect(findPanel().props('graphData')).toBe(null); + }); + + it('changing time range should not refetch data', () => { + store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_TIME_RANGE}`, mockTimeRange); + + return wrapper.vm.$nextTick(() => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when panel data is available', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_SUCCESS}`, mockPanel); + return wrapper.vm.$nextTick(); + }); + + it('displays no alert', () => { + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + + it('displays panel with data', () => { + const { title, type } = wrapper.find(DashboardPanel).props('graphData'); + + expect(title).toBe(mockPanel.title); + expect(type).toBe(mockPanel.type); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index 693818aa55a..fb96bcc042f 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -2,23 +2,23 @@ import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { setTestTimeout } from 'helpers/timeout'; +import { GlNewDropdownItem as GlDropdownItem } from '@gitlab/ui'; import invalidUrl from '~/lib/utils/invalid_url'; import axios from '~/lib/utils/axios_utils'; -import { GlNewDropdownItem as GlDropdownItem } from '@gitlab/ui'; import AlertWidget from '~/monitoring/components/alert_widget.vue'; import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { + mockAlert, mockLogsHref, mockLogsPath, mockNamespace, mockNamespacedData, mockTimeRange, - graphDataPrometheusQueryRangeMultiTrack, barMockData, } from '../mock_data'; import { dashboardProps, graphData, graphDataEmpty } from '../fixture_data'; -import { anomalyGraphData, singleStatGraphData } from '../graph_data'; +import { anomalyGraphData, singleStatGraphData, heatmapGraphData } from '../graph_data'; import { panelTypes } from '~/monitoring/constants'; @@ -56,9 +56,10 @@ describe('Dashboard Panel', () => { const findCtxMenu = () => wrapper.find({ ref: 'contextualMenu' }); const findMenuItems = () => wrapper.findAll(GlDropdownItem); const findMenuItemByText = text => findMenuItems().filter(i => i.text() === text); + const findAlertsWidget = () => wrapper.find(AlertWidget); - const createWrapper = (props, options) => { - wrapper = shallowMount(DashboardPanel, { + const createWrapper = (props, { mountFn = shallowMount, ...options } = {}) => { + wrapper = mountFn(DashboardPanel, { propsData: { graphData, settingsPath: dashboardProps.settingsPath, @@ -79,6 +80,9 @@ describe('Dashboard Panel', () => { }); }; + const setMetricsSavedToDb = val => + monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val); + beforeEach(() => { setTestTimeout(1000); @@ -235,7 +239,7 @@ describe('Dashboard Panel', () => { ${anomalyGraphData()} | ${MonitorAnomalyChart} | ${false} ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} | ${false} ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} | ${false} - ${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart} | ${false} + ${heatmapGraphData()} | ${MonitorHeatmapChart} | ${false} ${barMockData} | ${MonitorBarChart} | ${false} `('when $data.type data is provided', ({ data, component, hasCtxMenu }) => { const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' }; @@ -255,6 +259,35 @@ describe('Dashboard Panel', () => { }); }); }); + + describe('computed', () => { + describe('fixedCurrentTimeRange', () => { + it('returns fixed time for valid time range', () => { + state.timeRange = mockTimeRange; + return wrapper.vm.$nextTick(() => { + expect(findTimeChart().props('timeRange')).toEqual( + expect.objectContaining({ + start: expect.any(String), + end: expect.any(String), + }), + ); + }); + }); + + it.each` + input | output + ${''} | ${{}} + ${undefined} | ${{}} + ${null} | ${{}} + ${'2020-12-03'} | ${{}} + `('returns $output for invalid input like $input', ({ input, output }) => { + state.timeRange = input; + return wrapper.vm.$nextTick(() => { + expect(findTimeChart().props('timeRange')).toEqual(output); + }); + }); + }); + }); }); describe('Edit custom metric dropdown item', () => { @@ -444,7 +477,7 @@ describe('Dashboard Panel', () => { describe('csvText', () => { it('converts metrics data from json to csv', () => { - const header = `timestamp,${graphData.y_label}`; + const header = `timestamp,"${graphData.y_label} > ${graphData.metrics[0].label}"`; const data = graphData.metrics[0].result[0].values; const firstRow = `${data[0][0]},${data[0][1]}`; const secondRow = `${data[1][0]},${data[1][1]}`; @@ -523,7 +556,7 @@ describe('Dashboard Panel', () => { }); it('displays a heatmap in local timezone', () => { - createWrapper({ graphData: graphDataPrometheusQueryRangeMultiTrack }); + createWrapper({ graphData: heatmapGraphData() }); expect(wrapper.find(MonitorHeatmapChart).props('timezone')).toBe('LOCAL'); }); @@ -538,7 +571,7 @@ describe('Dashboard Panel', () => { }); it('displays a heatmap with UTC', () => { - createWrapper({ graphData: graphDataPrometheusQueryRangeMultiTrack }); + createWrapper({ graphData: heatmapGraphData() }); expect(wrapper.find(MonitorHeatmapChart).props('timezone')).toBe('UTC'); }); }); @@ -573,10 +606,6 @@ describe('Dashboard Panel', () => { }); describe('panel alerts', () => { - const setMetricsSavedToDb = val => - monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val); - const findAlertsWidget = () => wrapper.find(AlertWidget); - beforeEach(() => { mockGetterReturnValue('metricsSavedToDb', []); @@ -702,4 +731,60 @@ describe('Dashboard Panel', () => { expect(findManageLinksItem().exists()).toBe(false); }); }); + + describe('Runbook url', () => { + const findRunbookLinks = () => wrapper.findAll('[data-testid="runbookLink"]'); + const { metricId } = graphData.metrics[0]; + const { alert_path: alertPath } = mockAlert; + + const mockRunbookAlert = { + ...mockAlert, + metricId, + }; + + beforeEach(() => { + mockGetterReturnValue('metricsSavedToDb', []); + }); + + it('does not show a runbook link when alerts are not present', () => { + createWrapper(); + + expect(findRunbookLinks().length).toBe(0); + }); + + describe('when alerts are present', () => { + beforeEach(() => { + setMetricsSavedToDb([metricId]); + + createWrapper({ + alertsEndpoint: '/endpoint', + prometheusAlertsAvailable: true, + }); + }); + + it('does not show a runbook link when a runbook is not set', async () => { + findAlertsWidget().vm.$emit('setAlerts', alertPath, { + ...mockRunbookAlert, + runbookUrl: '', + }); + + await wrapper.vm.$nextTick(); + + expect(findRunbookLinks().length).toBe(0); + }); + + it('shows a runbook link when a runbook is set', async () => { + findAlertsWidget().vm.$emit('setAlerts', alertPath, mockRunbookAlert); + + await wrapper.vm.$nextTick(); + + expect(findRunbookLinks().length).toBe(1); + expect( + findRunbookLinks() + .at(0) + .attributes('href'), + ).toBe(invalidUrl); + }); + }); + }); }); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 4b7f7a9ddb3..f37d95317ab 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -1,19 +1,14 @@ import { shallowMount, mount } from '@vue/test-utils'; -import Tracking from '~/tracking'; -import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys'; -import { GlModal, GlDropdownItem, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; -import { objectToQuery } from '~/lib/utils/url_utility'; import VueDraggable from 'vuedraggable'; import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import { ESC_KEY } from '~/lib/utils/keys'; +import { objectToQuery } from '~/lib/utils/url_utility'; import axios from '~/lib/utils/axios_utils'; 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'; @@ -29,14 +24,13 @@ import { setupStoreWithDataForPanelCount, setupStoreWithLinks, } from '../store_utils'; -import { environmentData, dashboardGitResponse, storeVariables } from '../mock_data'; +import { dashboardGitResponse, storeVariables } from '../mock_data'; import { metricsDashboardViewModel, metricsDashboardPanelCount, dashboardProps, } from '../fixture_data'; -import createFlash from '~/flash'; -import { TEST_HOST } from 'helpers/test_constants'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); @@ -45,14 +39,6 @@ describe('Dashboard', () => { let wrapper; let mock; - const findDashboardHeader = () => wrapper.find(DashboardHeader); - const findEnvironmentsDropdown = () => - findDashboardHeader().find({ ref: 'monitorEnvironmentsDropdown' }); - const findAllEnvironmentsDropdownItems = () => findEnvironmentsDropdown().findAll(GlDropdownItem); - const setSearchTerm = searchTerm => { - store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm); - }; - const createShallowWrapper = (props = {}, options = {}) => { wrapper = shallowMount(Dashboard, { propsData: { ...dashboardProps, ...props }, @@ -90,28 +76,6 @@ describe('Dashboard', () => { } }); - describe('no metrics are available yet', () => { - beforeEach(() => { - createShallowWrapper(); - }); - - it('shows the environment selector', () => { - expect(findEnvironmentsDropdown().exists()).toBe(true); - }); - }); - - describe('no data found', () => { - beforeEach(() => { - createShallowWrapper(); - - return wrapper.vm.$nextTick(); - }); - - it('shows the environment selector dropdown', () => { - expect(findEnvironmentsDropdown().exists()).toBe(true); - }); - }); - describe('request information to the server', () => { it('calls to set time range and fetch data', () => { createShallowWrapper({ hasMetrics: true }); @@ -149,17 +113,14 @@ describe('Dashboard', () => { }); it('fetches the metrics data with proper time window', () => { - jest.spyOn(store, 'dispatch'); - createMountedWrapper({ hasMetrics: true }); - store.commit( - `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, - environmentData, - ); - return wrapper.vm.$nextTick().then(() => { - expect(store.dispatch).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/fetchData', undefined); + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/setTimeRange', + expect.objectContaining({ duration: { seconds: 28800 } }), + ); }); }); }); @@ -427,37 +388,6 @@ 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', () => { @@ -500,21 +430,6 @@ describe('Dashboard', () => { return wrapper.vm.$nextTick(); }); - it('renders the environments dropdown with a number of environments', () => { - expect(findAllEnvironmentsDropdownItems().length).toEqual(environmentData.length); - - findAllEnvironmentsDropdownItems().wrappers.forEach((itemWrapper, index) => { - const anchorEl = itemWrapper.find('a'); - if (anchorEl.exists()) { - const href = anchorEl.attributes('href'); - 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); @@ -524,127 +439,6 @@ describe('Dashboard', () => { }); }); }); - - // 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', () => { - const activeItem = findAllEnvironmentsDropdownItems().wrappers.filter(itemWrapper => - itemWrapper.find('.active').exists(), - ); - - expect(activeItem.length).toBe(1); - }); - }); - - describe('star dashboards', () => { - const findToggleStar = () => wrapper.find(DashboardHeader).find({ ref: 'toggleStarBtn' }); - const findToggleStarIcon = () => findToggleStar().find(GlIcon); - - beforeEach(() => { - createShallowWrapper(); - setupAllDashboards(store); - }); - - it('toggle star button is shown', () => { - expect(findToggleStar().exists()).toBe(true); - expect(findToggleStar().props('disabled')).toBe(false); - }); - - it('toggle star button is disabled when starring is taking place', () => { - store.commit(`monitoringDashboard/${types.REQUEST_DASHBOARD_STARRING}`); - - return wrapper.vm.$nextTick(() => { - expect(findToggleStar().exists()).toBe(true); - expect(findToggleStar().props('disabled')).toBe(true); - }); - }); - - describe('when the dashboard list is loaded', () => { - // Tooltip element should wrap directly - const getToggleTooltip = () => findToggleStar().element.parentElement.getAttribute('title'); - - beforeEach(() => { - setupAllDashboards(store); - jest.spyOn(store, 'dispatch'); - }); - - it('dispatches a toggle star action', () => { - findToggleStar().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/toggleStarredValue', - undefined, - ); - }); - }); - - describe('when dashboard is not starred', () => { - beforeEach(() => { - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard: dashboardGitResponse[0].path, - }); - return wrapper.vm.$nextTick(); - }); - - it('toggle star button shows "Star dashboard"', () => { - expect(getToggleTooltip()).toBe('Star dashboard'); - }); - - it('toggle star button shows an unstarred state', () => { - expect(findToggleStarIcon().attributes('name')).toBe('star-o'); - }); - }); - - describe('when dashboard is starred', () => { - beforeEach(() => { - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard: dashboardGitResponse[1].path, - }); - return wrapper.vm.$nextTick(); - }); - - it('toggle star button shows "Star dashboard"', () => { - expect(getToggleTooltip()).toBe('Unstar dashboard'); - }); - - it('toggle star button shows a starred state', () => { - expect(findToggleStarIcon().attributes('name')).toBe('star'); - }); - }); - }); - }); - - it('hides the environments dropdown list when there is no environments', () => { - createMountedWrapper({ hasMetrics: true }); - - setupStoreWithDashboard(store); - - return wrapper.vm.$nextTick().then(() => { - expect(findAllEnvironmentsDropdownItems()).toHaveLength(0); - }); - }); - - it('renders the datetimepicker dropdown', () => { - createMountedWrapper({ hasMetrics: true }); - - setupStoreWithData(store); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DateTimePicker).exists()).toBe(true); - }); - }); - - it('renders the refresh dashboard button', () => { - createMountedWrapper({ hasMetrics: true }); - - setupStoreWithData(store); - - return wrapper.vm.$nextTick().then(() => { - const refreshBtn = wrapper.find(DashboardHeader).find(RefreshButton); - - expect(refreshBtn.exists()).toBe(true); - }); }); describe('variables section', () => { @@ -772,15 +566,6 @@ describe('Dashboard', () => { undefined, ); }); - - it('restores dashboard from full screen by typing the Escape key on IE11', () => { - mockKeyup(ESC_KEY_IE11); - - expect(store.dispatch).toHaveBeenCalledWith( - `monitoringDashboard/clearExpandedPanel`, - undefined, - ); - }); }); }); @@ -811,100 +596,6 @@ describe('Dashboard', () => { }); }); - describe('searchable environments dropdown', () => { - beforeEach(() => { - createMountedWrapper({ hasMetrics: true }, { attachToDocument: true }); - - setupStoreWithData(store); - - return wrapper.vm.$nextTick(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders a search input', () => { - expect( - wrapper - .find(DashboardHeader) - .find({ ref: 'monitorEnvironmentsDropdownSearch' }) - .exists(), - ).toBe(true); - }); - - it('renders dropdown items', () => { - findAllEnvironmentsDropdownItems().wrappers.forEach((itemWrapper, index) => { - const anchorEl = itemWrapper.find('a'); - if (anchorEl.exists()) { - expect(anchorEl.text()).toBe(environmentData[index].name); - } - }); - }); - - it('filters rendered dropdown items', () => { - const searchTerm = 'production'; - const resultEnvs = environmentData.filter(({ name }) => name.indexOf(searchTerm) !== -1); - setSearchTerm(searchTerm); - - return wrapper.vm.$nextTick().then(() => { - expect(findAllEnvironmentsDropdownItems().length).toEqual(resultEnvs.length); - }); - }); - - it('does not filter dropdown items if search term is empty string', () => { - const searchTerm = ''; - setSearchTerm(searchTerm); - - return wrapper.vm.$nextTick(() => { - expect(findAllEnvironmentsDropdownItems().length).toEqual(environmentData.length); - }); - }); - - it("shows error message if search term doesn't match", () => { - const searchTerm = 'does-not-exist'; - setSearchTerm(searchTerm); - - return wrapper.vm.$nextTick(() => { - expect( - wrapper - .find(DashboardHeader) - .find({ ref: 'monitorEnvironmentsDropdownMsg' }) - .isVisible(), - ).toBe(true); - }); - }); - - it('shows loading element when environments fetch is still loading', () => { - store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`); - - return wrapper.vm - .$nextTick() - .then(() => { - expect( - wrapper - .find(DashboardHeader) - .find({ ref: 'monitorEnvironmentsDropdownLoading' }) - .exists(), - ).toBe(true); - }) - .then(() => { - store.commit( - `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, - environmentData, - ); - }) - .then(() => { - expect( - wrapper - .find(DashboardHeader) - .find({ ref: 'monitorEnvironmentsDropdownLoading' }) - .exists(), - ).toBe(false); - }); - }); - }); - describe('drag and drop function', () => { const findDraggables = () => wrapper.findAll(VueDraggable); const findEnabledDraggables = () => findDraggables().filter(f => !f.attributes('disabled')); @@ -998,57 +689,6 @@ describe('Dashboard', () => { }); }); - describe('dashboard timezone', () => { - const setupWithTimezone = value => { - store = createStore({ dashboardTimezone: value }); - setupStoreWithData(store); - createShallowWrapper({ hasMetrics: true }); - return wrapper.vm.$nextTick; - }; - - describe('local timezone is enabled by default', () => { - beforeEach(() => { - return setupWithTimezone(); - }); - - it('shows the data time picker in local timezone', () => { - expect( - findDashboardHeader() - .find(DateTimePicker) - .props('utc'), - ).toBe(false); - }); - }); - - describe('when LOCAL timezone is enabled', () => { - beforeEach(() => { - return setupWithTimezone('LOCAL'); - }); - - it('shows the data time picker in local timezone', () => { - expect( - findDashboardHeader() - .find(DateTimePicker) - .props('utc'), - ).toBe(false); - }); - }); - - describe('when UTC timezone is enabled', () => { - beforeEach(() => { - return setupWithTimezone('UTC'); - }); - - it('shows the data time picker in UTC format', () => { - expect( - findDashboardHeader() - .find(DateTimePicker) - .props('utc'), - ).toBe(true); - }); - }); - }); - describe('cluster health', () => { beforeEach(() => { createShallowWrapper({ hasMetrics: true, showHeader: false }); @@ -1068,36 +708,9 @@ describe('Dashboard', () => { }); }); - describe('dashboard edit link', () => { - const findEditLink = () => wrapper.find('.js-edit-link'); - - beforeEach(() => { - createShallowWrapper({ hasMetrics: true }); - - setupAllDashboards(store); - return wrapper.vm.$nextTick(); - }); - - it('is not present for the default dashboard', () => { - expect(findEditLink().exists()).toBe(false); - }); - - it('is present for a custom dashboard, and links to its edit_path', () => { - const dashboard = dashboardGitResponse[1]; - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard: dashboard.path, - }); - - return wrapper.vm.$nextTick().then(() => { - expect(findEditLink().exists()).toBe(true); - expect(findEditLink().attributes('href')).toBe(dashboard.project_blob_path); - }); - }); - }); - describe('document title', () => { const originalTitle = 'Original Title'; - const defaultDashboardName = dashboardGitResponse[0].display_name; + const overviewDashboardName = dashboardGitResponse[0].display_name; beforeEach(() => { document.title = originalTitle; @@ -1108,11 +721,11 @@ describe('Dashboard', () => { document.title = ''; }); - it('is prepended with default dashboard name by default', () => { + it('is prepended with the overview dashboard name by default', () => { setupAllDashboards(store); return wrapper.vm.$nextTick().then(() => { - expect(document.title.startsWith(`${defaultDashboardName} · `)).toBe(true); + expect(document.title.startsWith(`${overviewDashboardName} · `)).toBe(true); }); }); @@ -1127,11 +740,11 @@ describe('Dashboard', () => { }); }); - it('is prepended with default dashboard name is path is not known', () => { + it('is prepended with the overview dashboard name if path is not known', () => { setupAllDashboards(store, 'unknown/path'); return wrapper.vm.$nextTick().then(() => { - expect(document.title.startsWith(`${defaultDashboardName} · `)).toBe(true); + expect(document.title.startsWith(`${overviewDashboardName} · `)).toBe(true); }); }); @@ -1151,41 +764,6 @@ describe('Dashboard', () => { }); }); - describe('Dashboard dropdown', () => { - beforeEach(() => { - createMountedWrapper({ hasMetrics: true }); - setupAllDashboards(store); - return wrapper.vm.$nextTick(); - }); - - it('shows the dashboard dropdown', () => { - const dashboardDropdown = wrapper.find(DashboardsDropdown); - - expect(dashboardDropdown.exists()).toBe(true); - }); - }); - - describe('external dashboard link', () => { - beforeEach(() => { - createMountedWrapper({ - hasMetrics: true, - showPanels: false, - showTimeWindowDropdown: false, - externalDashboardUrl: '/mockUrl', - }); - - return wrapper.vm.$nextTick(); - }); - - it('shows the link', () => { - const externalDashboardButton = wrapper.find('.js-external-dashboard-link'); - - expect(externalDashboardButton.exists()).toBe(true); - expect(externalDashboardButton.is(GlDeprecatedButton)).toBe(true); - expect(externalDashboardButton.text()).toContain('View full dashboard'); - }); - }); - describe('Clipboard text in panels', () => { const currentDashboard = dashboardGitResponse[1].path; const panelIndex = 1; // skip expanded panel @@ -1243,74 +821,4 @@ describe('Dashboard', () => { expect(dashboardPanel.exists()).toBe(true); }); }); - - describe('add custom metrics', () => { - const findAddMetricButton = () => wrapper.find(DashboardHeader).find({ ref: 'addMetricBtn' }); - - describe('when not available', () => { - beforeEach(() => { - createShallowWrapper({ - hasMetrics: true, - customMetricsPath: '/endpoint', - }); - }); - it('does not render add button on the dashboard', () => { - expect(findAddMetricButton().exists()).toBe(false); - }); - }); - - describe('when available', () => { - let origPage; - beforeEach(done => { - jest.spyOn(Tracking, 'event').mockReturnValue(); - createShallowWrapper({ - hasMetrics: true, - customMetricsPath: '/endpoint', - customMetricsAvailable: true, - }); - setupStoreWithData(store); - - origPage = document.body.dataset.page; - document.body.dataset.page = 'projects:environments:metrics'; - - wrapper.vm.$nextTick(done); - }); - afterEach(() => { - document.body.dataset.page = origPage; - }); - - it('renders add button on the dashboard', () => { - expect(findAddMetricButton()).toBeDefined(); - }); - - it('uses modal for custom metrics form', () => { - expect(wrapper.find(GlModal).exists()).toBe(true); - expect(wrapper.find(GlModal).attributes().modalid).toBe('addMetric'); - }); - it('adding new metric is tracked', done => { - const submitButton = wrapper - .find(DashboardHeader) - .find({ ref: 'submitCustomMetricsFormBtn' }).vm; - wrapper.vm.$nextTick(() => { - submitButton.$el.click(); - wrapper.vm.$nextTick(() => { - expect(Tracking.event).toHaveBeenCalledWith( - document.body.dataset.page, - 'click_button', - { - label: 'add_new_metric', - property: 'modal', - value: undefined, - }, - ); - done(); - }); - }); - }); - - it('renders custom metrics form fields', () => { - expect(wrapper.find(CustomMetricsFormFields).exists()).toBe(true); - }); - }); - }); }); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index 276e20bae6a..c4630bde32f 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { queryToObject, redirectTo, diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index d09fcc92ee7..89adbad386f 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -1,12 +1,11 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlNewDropdownItem, GlIcon } from '@gitlab/ui'; import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; -import { dashboardGitResponse, selfMonitoringDashboardGitResponse } from '../mock_data'; +import { dashboardGitResponse } from '../mock_data'; const defaultBranch = 'master'; -const modalId = 'duplicateDashboardModalId'; const starredDashboards = dashboardGitResponse.filter(({ starred }) => starred); const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starred); @@ -17,20 +16,16 @@ describe('DashboardsDropdown', () => { function createComponent(props, opts = {}) { const storeOpts = { - methods: { - duplicateSystemDashboard: jest.fn(), - }, computed: { allDashboards: () => mockDashboards, selectedDashboard: () => mockSelectedDashboard, }, }; - return shallowMount(DashboardsDropdown, { + wrapper = shallowMount(DashboardsDropdown, { propsData: { ...props, defaultBranch, - modalId, }, sync: false, ...storeOpts, @@ -38,8 +33,8 @@ describe('DashboardsDropdown', () => { }); } - const findItems = () => wrapper.findAll(GlDropdownItem); - const findItemAt = i => wrapper.findAll(GlDropdownItem).at(i); + const findItems = () => wrapper.findAll(GlNewDropdownItem); + const findItemAt = i => wrapper.findAll(GlNewDropdownItem).at(i); const findSearchInput = () => wrapper.find({ ref: 'monitorDashboardsDropdownSearch' }); const findNoItemsMsg = () => wrapper.find({ ref: 'monitorDashboardsDropdownMsg' }); const findStarredListDivider = () => wrapper.find({ ref: 'starredListDivider' }); @@ -52,7 +47,7 @@ describe('DashboardsDropdown', () => { describe('when it receives dashboards data', () => { beforeEach(() => { - wrapper = createComponent(); + createComponent(); }); it('displays an item for each dashboard', () => { @@ -78,7 +73,7 @@ describe('DashboardsDropdown', () => { }); it('filters dropdown items when searched for item exists in the list', () => { - const searchTerm = 'Default'; + const searchTerm = 'Overview'; setSearchTerm(searchTerm); return wrapper.vm.$nextTick().then(() => { @@ -96,10 +91,22 @@ describe('DashboardsDropdown', () => { }); }); + describe('when a dashboard is selected', () => { + beforeEach(() => { + [mockSelectedDashboard] = starredDashboards; + createComponent(); + }); + + it('dashboard item is selected', () => { + expect(findItemAt(0).props('isChecked')).toBe(true); + expect(findItemAt(1).props('isChecked')).toBe(false); + }); + }); + describe('when the dashboard is missing a display name', () => { beforeEach(() => { mockDashboards = dashboardGitResponse.map(d => ({ ...d, display_name: undefined })); - wrapper = createComponent(); + createComponent(); }); it('displays items with the dashboard path, with starred dashboards first', () => { @@ -112,7 +119,7 @@ describe('DashboardsDropdown', () => { describe('when it receives starred dashboards', () => { beforeEach(() => { mockDashboards = starredDashboards; - wrapper = createComponent(); + createComponent(); }); it('displays an item for each dashboard', () => { @@ -133,7 +140,7 @@ describe('DashboardsDropdown', () => { describe('when it receives only not-starred dashboards', () => { beforeEach(() => { mockDashboards = notStarredDashboards; - wrapper = createComponent(); + createComponent(); }); it('displays an item for each dashboard', () => { @@ -150,90 +157,9 @@ describe('DashboardsDropdown', () => { }); }); - 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 = dashboard; - modalDirective = jest.fn(); - duplicateDashboardAction = jest.fn().mockResolvedValue(); - - wrapper = createComponent( - {}, - { - directives: { - GlModal: modalDirective, - }, - methods: { - // Mock vuex actions - duplicateSystemDashboard: duplicateDashboardAction, - }, - }, - ); - }); - - it('displays a dropdown item for each dashboard', () => { - expect(findItems().length).toEqual(dashboardGitResponse.length + 1); - }); - - it('displays one "duplicate dashboard" dropdown item with a directive attached', () => { - const item = wrapper.findAll('[data-testid="duplicateDashboardItem"]'); - - expect(item.length).toBe(1); - }); - - it('"duplicate dashboard" dropdown item directive works', () => { - const item = wrapper.find('[data-testid="duplicateDashboardItem"]'); - - item.trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(modalDirective).toHaveBeenCalled(); - }); - }); - - 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: modalId, - }), - ); - }); - }); - - const nonDuplicableCases = [dashboardGitResponse[1], selfMonitoringDashboardGitResponse[1]]; - - describe.each(nonDuplicableCases)( - 'when the selected dashboard can not be duplicated', - dashboard => { - beforeEach(() => { - mockSelectedDashboard = dashboard; - - wrapper = createComponent(); - }); - - 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); - }); - }, - ); - describe('when a dashboard gets selected by the user', () => { beforeEach(() => { - wrapper = createComponent(); + createComponent(); findItemAt(1).vm.$emit('click'); }); diff --git a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js index 4e7fee81d66..74f265930b1 100644 --- a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js +++ b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js @@ -1,10 +1,10 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; -import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { TEST_HOST } from 'helpers/test_constants'; +import { setHTMLFixture } from 'helpers/fixtures'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue'; import { groups, initialState, metricsData, metricsWithData } from './mock_data'; -import { setHTMLFixture } from 'helpers/fixtures'; const localVue = createLocalVue(); localVue.use(Vuex); diff --git a/spec/frontend/monitoring/components/graph_group_spec.js b/spec/frontend/monitoring/components/graph_group_spec.js index 81f5d90c310..86e2523f708 100644 --- a/spec/frontend/monitoring/components/graph_group_spec.js +++ b/spec/frontend/monitoring/components/graph_group_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import GraphGroup from '~/monitoring/components/graph_group.vue'; import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import GraphGroup from '~/monitoring/components/graph_group.vue'; describe('Graph group component', () => { let wrapper; diff --git a/spec/frontend/monitoring/components/group_empty_state_spec.js b/spec/frontend/monitoring/components/group_empty_state_spec.js index e8ef8192067..90bd6f67196 100644 --- a/spec/frontend/monitoring/components/group_empty_state_spec.js +++ b/spec/frontend/monitoring/components/group_empty_state_spec.js @@ -24,7 +24,7 @@ describe('GroupEmptyState', () => { 'FOO STATE', // does not fail with unknown states ]; - test.each(supportedStates)('Renders an empty state for %s', selectedState => { + it.each(supportedStates)('Renders an empty state for %s', selectedState => { const wrapper = createComponent({ selectedState }); expect(wrapper.element).toMatchSnapshot(); diff --git a/spec/frontend/monitoring/components/refresh_button_spec.js b/spec/frontend/monitoring/components/refresh_button_spec.js index 29615638453..a9b8295f38e 100644 --- a/spec/frontend/monitoring/components/refresh_button_spec.js +++ b/spec/frontend/monitoring/components/refresh_button_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; -import { createStore } from '~/monitoring/stores'; +import Visibility from 'visibilityjs'; import { GlNewDropdown, GlNewDropdownItem, GlButton } from '@gitlab/ui'; - +import { createStore } from '~/monitoring/stores'; import RefreshButton from '~/monitoring/components/refresh_button.vue'; describe('RefreshButton', () => { @@ -10,8 +10,8 @@ describe('RefreshButton', () => { let dispatch; let documentHidden; - const createWrapper = () => { - wrapper = shallowMount(RefreshButton, { store }); + const createWrapper = (options = {}) => { + wrapper = shallowMount(RefreshButton, { store, ...options }); }; const findRefreshBtn = () => wrapper.find(GlButton); @@ -31,14 +31,8 @@ describe('RefreshButton', () => { 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; - }, - }); + jest.spyOn(Visibility, 'hidden').mockImplementation(() => documentHidden); createWrapper(); }); @@ -57,6 +51,20 @@ describe('RefreshButton', () => { expect(findDropdown().props('text')).toBe('Off'); }); + describe('when feature flag disable_metric_dashboard_refresh_rate is on', () => { + beforeEach(() => { + createWrapper({ + provide: { + glFeatures: { disableMetricDashboardRefreshRate: true }, + }, + }); + }); + + it('refresh rate is not available', () => { + expect(findDropdown().exists()).toBe(false); + }); + }); + describe('refresh rate options', () => { it('presents multiple options', () => { expect(findOptions().length).toBeGreaterThan(1); diff --git a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js index cc384aef231..788f3abf617 100644 --- a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js +++ b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; describe('Custom variable component', () => { @@ -23,8 +23,8 @@ describe('Custom variable component', () => { }); }; - const findDropdown = () => wrapper.find(GlDropdown); - const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findDropdown = () => wrapper.find(GlDeprecatedDropdown); + const findDropdownItems = () => wrapper.findAll(GlDeprecatedDropdownItem); it('renders dropdown element when all necessary props are passed', () => { createShallowWrapper(); diff --git a/spec/frontend/monitoring/csv_export_spec.js b/spec/frontend/monitoring/csv_export_spec.js new file mode 100644 index 00000000000..eb2a6e40243 --- /dev/null +++ b/spec/frontend/monitoring/csv_export_spec.js @@ -0,0 +1,126 @@ +import { timeSeriesGraphData } from './graph_data'; +import { graphDataToCsv } from '~/monitoring/csv_export'; + +describe('monitoring export_csv', () => { + describe('graphDataToCsv', () => { + const expectCsvToMatchLines = (csv, lines) => expect(`${lines.join('\r\n')}\r\n`).toEqual(csv); + + it('should return a csv with 0 metrics', () => { + const data = timeSeriesGraphData({}, { metricCount: 0 }); + + expect(graphDataToCsv(data)).toEqual(''); + }); + + it('should return a csv with 1 metric with no data', () => { + const data = timeSeriesGraphData({}, { metricCount: 1 }); + + // When state is NO_DATA, result is null + data.metrics[0].result = null; + + expect(graphDataToCsv(data)).toEqual(''); + }); + + it('should return a csv with 1 metric', () => { + const data = timeSeriesGraphData({}, { metricCount: 1 }); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > Metric 1"`, + '2015-07-01T20:10:50.000Z,1', + '2015-07-01T20:12:50.000Z,2', + '2015-07-01T20:14:50.000Z,3', + ]); + }); + + it('should return a csv with multiple metrics and one with no data', () => { + const data = timeSeriesGraphData({}, { metricCount: 2 }); + + // When state is NO_DATA, result is null + data.metrics[0].result = null; + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > Metric 2"`, + '2015-07-01T20:10:50.000Z,1', + '2015-07-01T20:12:50.000Z,2', + '2015-07-01T20:14:50.000Z,3', + ]); + }); + + it('should return a csv when not all metrics have the same timestamps', () => { + const data = timeSeriesGraphData({}, { metricCount: 3 }); + + // Add an "odd" timestamp that is not in the dataset + Object.assign(data.metrics[2].result[0], { + value: ['2016-01-01T00:00:00.000Z', 9], + values: [['2016-01-01T00:00:00.000Z', 9]], + }); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > Metric 1","Y Axis > Metric 2","Y Axis > Metric 3"`, + '2015-07-01T20:10:50.000Z,1,1,', + '2015-07-01T20:12:50.000Z,2,2,', + '2015-07-01T20:14:50.000Z,3,3,', + '2016-01-01T00:00:00.000Z,,,9', + ]); + }); + + it('should escape double quotes in metric labels with two double quotes ("")', () => { + const data = timeSeriesGraphData({}, { metricCount: 1 }); + + data.metrics[0].label = 'My "quoted" metric'; + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > My ""quoted"" metric"`, + '2015-07-01T20:10:50.000Z,1', + '2015-07-01T20:12:50.000Z,2', + '2015-07-01T20:14:50.000Z,3', + ]); + }); + + it('should return a csv with multiple metrics', () => { + const data = timeSeriesGraphData({}, { metricCount: 3 }); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > Metric 1","Y Axis > Metric 2","Y Axis > Metric 3"`, + '2015-07-01T20:10:50.000Z,1,1,1', + '2015-07-01T20:12:50.000Z,2,2,2', + '2015-07-01T20:14:50.000Z,3,3,3', + ]); + }); + + it('should return a csv with 1 metric and multiple series with labels', () => { + const data = timeSeriesGraphData({}, { isMultiSeries: true }); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > Metric 1","Y Axis > Metric 1"`, + '2015-07-01T20:10:50.000Z,1,4', + '2015-07-01T20:12:50.000Z,2,5', + '2015-07-01T20:14:50.000Z,3,6', + ]); + }); + + it('should return a csv with 1 metric and multiple series', () => { + const data = timeSeriesGraphData({}, { isMultiSeries: true, withLabels: false }); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091"`, + '2015-07-01T20:10:50.000Z,1,4', + '2015-07-01T20:12:50.000Z,2,5', + '2015-07-01T20:14:50.000Z,3,6', + ]); + }); + + it('should return a csv with multiple metrics and multiple series', () => { + const data = timeSeriesGraphData( + {}, + { metricCount: 3, isMultiSeries: true, withLabels: false }, + ); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091","Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091","Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091"`, + '2015-07-01T20:10:50.000Z,1,4,1,4,1,4', + '2015-07-01T20:12:50.000Z,2,5,2,5,2,5', + '2015-07-01T20:14:50.000Z,3,6,3,6,3,6', + ]); + }); + }); +}); diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js index 97edf7bda74..30040d3f89f 100644 --- a/spec/frontend/monitoring/fixture_data.js +++ b/spec/frontend/monitoring/fixture_data.js @@ -29,36 +29,12 @@ const datasetState = stateAndPropsFromDataset( // 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', - 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', - data: { - resultType: 'matrix', - result: metricsResult, - }, -}; -export const metricResultEmpty = { - metricId: 'NO_DB_response_metrics_nginx_ingress_16_throughput_status_code', - data: { - resultType: 'matrix', - result: [], - }, -}; // Graph data diff --git a/spec/frontend/monitoring/graph_data.js b/spec/frontend/monitoring/graph_data.js index e1b95723f3d..f85351e55d7 100644 --- a/spec/frontend/monitoring/graph_data.js +++ b/spec/frontend/monitoring/graph_data.js @@ -1,10 +1,38 @@ import { mapPanelToViewModel, normalizeQueryResponseData } from '~/monitoring/stores/utils'; import { panelTypes, metricStates } from '~/monitoring/constants'; -const initTime = 1435781451.781; +const initTime = 1435781450; // "Wed, 01 Jul 2015 20:10:50 GMT" +const intervalSeconds = 120; const makeValue = val => [initTime, val]; -const makeValues = vals => vals.map((val, i) => [initTime + 15 * i, val]); +const makeValues = vals => vals.map((val, i) => [initTime + intervalSeconds * i, val]); + +// Raw Promethues Responses + +export const prometheusMatrixMultiResult = ({ + values1 = ['1', '2', '3'], + values2 = ['4', '5', '6'], +} = {}) => ({ + 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), + }, + ], +}); // Normalized Prometheus Responses @@ -82,7 +110,7 @@ const matrixMultiResult = ({ values1 = ['1', '2', '3'], values2 = ['4', '5', '6' * @param {Object} dataOptions.isMultiSeries */ export const timeSeriesGraphData = (panelOptions = {}, dataOptions = {}) => { - const { metricCount = 1, isMultiSeries = false } = dataOptions; + const { metricCount = 1, isMultiSeries = false, withLabels = true } = dataOptions; return mapPanelToViewModel({ title: 'Time Series Panel', @@ -90,7 +118,7 @@ export const timeSeriesGraphData = (panelOptions = {}, dataOptions = {}) => { x_label: 'X Axis', y_label: 'Y Axis', metrics: Array.from(Array(metricCount), (_, i) => ({ - label: `Metric ${i + 1}`, + label: withLabels ? `Metric ${i + 1}` : undefined, state: metricStates.OK, result: isMultiSeries ? matrixMultiResult() : matrixSingleResult(), })), @@ -162,3 +190,59 @@ export const anomalyGraphData = (panelOptions = {}, dataOptions = {}) => { ...panelOptions, }); }; + +/** + * Generate mock graph data for heatmaps according to options + */ +export const heatmapGraphData = (panelOptions = {}, dataOptions = {}) => { + const { metricCount = 1 } = dataOptions; + + return mapPanelToViewModel({ + title: 'Heatmap Panel', + type: panelTypes.HEATMAP, + x_label: 'X Axis', + y_label: 'Y Axis', + metrics: Array.from(Array(metricCount), (_, i) => ({ + label: `Metric ${i + 1}`, + state: metricStates.OK, + result: matrixMultiResult(), + })), + ...panelOptions, + }); +}; + +/** + * Generate gauge chart mock graph data according to options + * + * @param {Object} panelOptions - Panel options as in YML. + * + */ +export const gaugeChartGraphData = (panelOptions = {}) => { + const { + minValue = 100, + maxValue = 1000, + split = 20, + thresholds = { + mode: 'absolute', + values: [500, 800], + }, + format = 'kilobytes', + } = panelOptions; + + return mapPanelToViewModel({ + title: 'Gauge Chart Panel', + type: panelTypes.GAUGE_CHART, + min_value: minValue, + max_value: maxValue, + split, + thresholds, + format, + metrics: [ + { + label: `Metric`, + state: metricStates.OK, + result: matrixSingleResult(), + }, + ], + }); +}; diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 49ad33402c6..28a7dd1af4f 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -1,3 +1,4 @@ +import invalidUrl from '~/lib/utils/invalid_url'; // This import path needs to be relative for now because this mock data is used in // Karma specs too, where the helpers/test_constants alias can not be resolved import { TEST_HOST } from '../helpers/test_constants'; @@ -170,7 +171,7 @@ export const environmentData = [ export const dashboardGitResponse = [ { default: true, - display_name: 'Default', + display_name: 'Overview', can_edit: false, system_dashboard: true, out_of_the_box_dashboard: true, @@ -209,7 +210,7 @@ export const selfMonitoringDashboardGitResponse = [ default: true, display_name: 'Default', can_edit: false, - system_dashboard: false, + system_dashboard: true, out_of_the_box_dashboard: true, project_blob_path: null, path: 'config/prometheus/self_monitoring_default.yml', @@ -244,83 +245,6 @@ export const metricsResult = [ }, ]; -export const graphDataPrometheusQueryRangeMultiTrack = { - title: 'Super Chart A3', - type: 'heatmap', - weight: 3, - x_label: 'Status Code', - y_label: 'Time', - metrics: [ - { - metricId: '1_metric_b', - id: 'response_metrics_nginx_ingress_throughput_status_code', - query_range: - 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[60m])) by (status_code)', - unit: 'req / sec', - label: 'Status Code', - prometheus_endpoint_path: - '/root/rails_nodb/environments/3/prometheus/api/v1/query_range?query=sum%28rate%28nginx_upstream_responses_total%7Bupstream%3D~%22%25%7Bkube_namespace%7D-%25%7Bci_environment_slug%7D-.%2A%22%7D%5B2m%5D%29%29+by+%28status_code%29', - result: [ - { - metric: { status_code: '1xx' }, - values: [ - ['2019-08-30T15:00:00.000Z', 0], - ['2019-08-30T16:00:00.000Z', 2], - ['2019-08-30T17:00:00.000Z', 0], - ['2019-08-30T18:00:00.000Z', 0], - ['2019-08-30T19:00:00.000Z', 0], - ['2019-08-30T20:00:00.000Z', 3], - ], - }, - { - metric: { status_code: '2xx' }, - values: [ - ['2019-08-30T15:00:00.000Z', 1], - ['2019-08-30T16:00:00.000Z', 3], - ['2019-08-30T17:00:00.000Z', 6], - ['2019-08-30T18:00:00.000Z', 10], - ['2019-08-30T19:00:00.000Z', 8], - ['2019-08-30T20:00:00.000Z', 6], - ], - }, - { - metric: { status_code: '3xx' }, - values: [ - ['2019-08-30T15:00:00.000Z', 1], - ['2019-08-30T16:00:00.000Z', 2], - ['2019-08-30T17:00:00.000Z', 3], - ['2019-08-30T18:00:00.000Z', 3], - ['2019-08-30T19:00:00.000Z', 2], - ['2019-08-30T20:00:00.000Z', 1], - ], - }, - { - metric: { status_code: '4xx' }, - values: [ - ['2019-08-30T15:00:00.000Z', 2], - ['2019-08-30T16:00:00.000Z', 0], - ['2019-08-30T17:00:00.000Z', 0], - ['2019-08-30T18:00:00.000Z', 2], - ['2019-08-30T19:00:00.000Z', 0], - ['2019-08-30T20:00:00.000Z', 2], - ], - }, - { - metric: { status_code: '5xx' }, - values: [ - ['2019-08-30T15:00:00.000Z', 0], - ['2019-08-30T16:00:00.000Z', 1], - ['2019-08-30T17:00:00.000Z', 0], - ['2019-08-30T18:00:00.000Z', 0], - ['2019-08-30T19:00:00.000Z', 0], - ['2019-08-30T20:00:00.000Z', 2], - ], - }, - ], - }, - ], -}; - export const stackedColumnMockedData = { title: 'memories', type: 'stacked-column', @@ -420,6 +344,11 @@ export const mockNamespaces = [`${baseNamespace}/1`, `${baseNamespace}/2`]; export const mockTimeRange = { duration: { seconds: 120 } }; +export const mockFixedTimeRange = { + start: '2020-06-17T19:59:08.659Z', + end: '2020-07-17T19:59:08.659Z', +}; + export const mockNamespacedData = { mockDeploymentData: ['mockDeploymentData'], mockProjectPath: '/mockProjectPath', @@ -688,10 +617,28 @@ export const storeVariables = [ 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', }, }; + +export const dashboardActionsMenuProps = { + defaultBranch: 'master', + addingMetricsAvailable: true, + customMetricsPath: 'https://path/to/customMetrics', + validateQueryPath: 'https://path/to/validateQuery', + isOotbDashboard: true, +}; + +export const mockAlert = { + alert_path: 'alert_path', + id: 8, + metricId: 'mock_metric_id', + operator: '>', + query: 'testQuery', + runbookUrl: invalidUrl, + threshold: 5, + title: 'alert title', +}; diff --git a/spec/frontend/monitoring/pages/panel_new_page_spec.js b/spec/frontend/monitoring/pages/panel_new_page_spec.js new file mode 100644 index 00000000000..83365b754d9 --- /dev/null +++ b/spec/frontend/monitoring/pages/panel_new_page_spec.js @@ -0,0 +1,98 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from '~/monitoring/router/constants'; +import { createStore } from '~/monitoring/stores'; +import DashboardPanelBuilder from '~/monitoring/components/dashboard_panel_builder.vue'; + +import PanelNewPage from '~/monitoring/pages/panel_new_page.vue'; + +const dashboard = 'dashboard.yml'; + +// Button stub that can accept `to` as router links do +// https://bootstrap-vue.org/docs/components/button#comp-ref-b-button-props +const GlButtonStub = { + extends: GlButton, + props: { + to: [String, Object], + }, +}; + +describe('monitoring/pages/panel_new_page', () => { + let store; + let wrapper; + let $route; + let $router; + + const mountComponent = (propsData = {}, route) => { + $route = route ?? { name: PANEL_NEW_PAGE, params: { dashboard } }; + $router = { + push: jest.fn(), + }; + + wrapper = shallowMount(PanelNewPage, { + propsData, + store, + stubs: { + GlButton: GlButtonStub, + }, + mocks: { + $router, + $route, + }, + }); + }; + + const findBackButton = () => wrapper.find(GlButtonStub); + const findPanelBuilder = () => wrapper.find(DashboardPanelBuilder); + + beforeEach(() => { + store = createStore(); + mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('back to dashboard button', () => { + it('is rendered', () => { + expect(findBackButton().exists()).toBe(true); + expect(findBackButton().props('icon')).toBe('go-back'); + }); + + it('links back to the dashboard', () => { + expect(findBackButton().props('to')).toEqual({ + name: DASHBOARD_PAGE, + params: { dashboard }, + }); + }); + + it('links back to the dashboard while preserving query params', () => { + $route = { + name: PANEL_NEW_PAGE, + params: { dashboard }, + query: { another: 'param' }, + }; + + mountComponent({}, $route); + + expect(findBackButton().props('to')).toEqual({ + name: DASHBOARD_PAGE, + params: { dashboard }, + query: { another: 'param' }, + }); + }); + }); + + describe('dashboard panel builder', () => { + it('is rendered', () => { + expect(findPanelBuilder().exists()).toBe(true); + }); + }); + + describe('page routing', () => { + it('route is not updated by default', () => { + expect($router.push).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/monitoring/requests/index_spec.js b/spec/frontend/monitoring/requests/index_spec.js new file mode 100644 index 00000000000..a91c209875a --- /dev/null +++ b/spec/frontend/monitoring/requests/index_spec.js @@ -0,0 +1,149 @@ +import MockAdapter from 'axios-mock-adapter'; +import { backoffMockImplementation } from 'jest/helpers/backoff_helper'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { metricsDashboardResponse } from '../fixture_data'; +import { getDashboard, getPrometheusQueryData } from '~/monitoring/requests'; + +describe('monitoring metrics_requests', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(commonUtils, 'backOff').mockImplementation(backoffMockImplementation); + }); + + afterEach(() => { + mock.reset(); + + commonUtils.backOff.mockReset(); + }); + + describe('getDashboard', () => { + const response = metricsDashboardResponse; + const dashboardEndpoint = '/dashboard'; + const params = { + start_time: 'start_time', + end_time: 'end_time', + }; + + it('returns a dashboard response', () => { + mock.onGet(dashboardEndpoint).reply(statusCodes.OK, response); + + return getDashboard(dashboardEndpoint, params).then(data => { + expect(data).toEqual(metricsDashboardResponse); + }); + }); + + it('returns a dashboard response after retrying twice', () => { + mock.onGet(dashboardEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(dashboardEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(dashboardEndpoint).reply(statusCodes.OK, response); + + return getDashboard(dashboardEndpoint, params).then(data => { + expect(data).toEqual(metricsDashboardResponse); + expect(mock.history.get).toHaveLength(3); + }); + }); + + it('rejects after getting an error', () => { + mock.onGet(dashboardEndpoint).reply(500); + + return getDashboard(dashboardEndpoint, params).catch(error => { + expect(error).toEqual(expect.any(Error)); + expect(mock.history.get).toHaveLength(1); + }); + }); + }); + + describe('getPrometheusQueryData', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [], + }, + }; + const prometheusEndpoint = '/query_range'; + const params = { + start_time: 'start_time', + end_time: 'end_time', + }; + + it('returns a dashboard response', () => { + mock.onGet(prometheusEndpoint).reply(statusCodes.OK, response); + + return getPrometheusQueryData(prometheusEndpoint, params).then(data => { + expect(data).toEqual(response.data); + }); + }); + + it('returns a dashboard response after retrying twice', () => { + // Mock multiple attempts while the cache is filling up + mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(prometheusEndpoint).reply(statusCodes.OK, response); // 3rd attempt + + return getPrometheusQueryData(prometheusEndpoint, params).then(data => { + expect(data).toEqual(response.data); + expect(mock.history.get).toHaveLength(3); + }); + }); + + it('rejects after getting an HTTP 500 error', () => { + mock.onGet(prometheusEndpoint).reply(500, { + status: 'error', + error: 'An error ocurred', + }); + + return getPrometheusQueryData(prometheusEndpoint, params).catch(error => { + expect(error).toEqual(new Error('Request failed with status code 500')); + }); + }); + + it('rejects after retrying twice and getting an HTTP 401 error', () => { + // Mock multiple attempts while the cache is filling up and fails + mock.onGet(prometheusEndpoint).reply(statusCodes.UNAUTHORIZED, { + status: 'error', + error: 'An error ocurred', + }); + + return getPrometheusQueryData(prometheusEndpoint, params).catch(error => { + expect(error).toEqual(new Error('Request failed with status code 401')); + }); + }); + + it('rejects after retrying twice and getting an HTTP 500 error', () => { + // Mock multiple attempts while the cache is filling up and fails + mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(prometheusEndpoint).reply(500, { + status: 'error', + error: 'An error ocurred', + }); // 3rd attempt + + return getPrometheusQueryData(prometheusEndpoint, params).catch(error => { + expect(error).toEqual(new Error('Request failed with status code 500')); + expect(mock.history.get).toHaveLength(3); + }); + }); + + test.each` + code | reason + ${statusCodes.BAD_REQUEST} | ${'Parameters are missing or incorrect'} + ${statusCodes.UNPROCESSABLE_ENTITY} | ${"Expression can't be executed"} + ${statusCodes.SERVICE_UNAVAILABLE} | ${'Query timed out or aborted'} + `('rejects with details: "$reason" after getting an HTTP $code error', ({ code, reason }) => { + mock.onGet(prometheusEndpoint).reply(code, { + status: 'error', + error: reason, + }); + + return getPrometheusQueryData(prometheusEndpoint, params).catch(error => { + expect(error).toEqual(new Error(reason)); + expect(mock.history.get).toHaveLength(1); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/router_spec.js b/spec/frontend/monitoring/router_spec.js index 5b8f4b3c83e..8b97c8ed125 100644 --- a/spec/frontend/monitoring/router_spec.js +++ b/spec/frontend/monitoring/router_spec.js @@ -1,18 +1,28 @@ import { mount, createLocalVue } from '@vue/test-utils'; import VueRouter from 'vue-router'; import DashboardPage from '~/monitoring/pages/dashboard_page.vue'; +import PanelNewPage from '~/monitoring/pages/panel_new_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'; +const LEGACY_BASE_PATH = '/project/my-group/test-project/-/environments/71146/metrics'; +const BASE_PATH = '/project/my-group/test-project/-/metrics'; + +const MockApp = { + data() { + return { + dashboardProps: { ...dashboardProps, ...dashboardHeaderProps }, + }; + }, + template: `<router-view :dashboard-props="dashboardProps"/>`, +}; + 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(); @@ -23,11 +33,10 @@ describe('Monitoring router', () => { router.push(routeArg); } - return mount(DashboardPage, { + return mount(MockApp, { localVue, store, router, - propsData, }); }; @@ -40,26 +49,32 @@ describe('Monitoring router', () => { window.location.hash = ''; }); - describe('support old URL with full dashboard path', () => { + describe('support legacy URLs with full dashboard path to visit dashboard page', () => { it.each` - route | currentDashboard + path | 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); + `('"$path" renders page with dashboard "$currentDashboard"', ({ path, currentDashboard }) => { + const wrapper = createWrapper(LEGACY_BASE_PATH, path); expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', { currentDashboard, }); - expect(wrapper.find(Dashboard)).toExist(); + expect(wrapper.find(DashboardPage).exists()).toBe(true); + expect( + wrapper + .find(DashboardPage) + .find(Dashboard) + .exists(), + ).toBe(true); }); }); - describe('supports new URL with short dashboard path', () => { + describe('supports URLs to visit dashboard page', () => { it.each` - route | currentDashboard + path | currentDashboard ${'/'} | ${null} ${'/dashboard.yml'} | ${'dashboard.yml'} ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'} @@ -68,14 +83,35 @@ describe('Monitoring router', () => { ${'/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); + `('"$path" renders page with dashboard "$currentDashboard"', ({ path, currentDashboard }) => { + const wrapper = createWrapper(BASE_PATH, path); expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', { currentDashboard, }); - expect(wrapper.find(Dashboard)).toExist(); + expect(wrapper.find(DashboardPage).exists()).toBe(true); + expect( + wrapper + .find(DashboardPage) + .find(Dashboard) + .exists(), + ).toBe(true); + }); + }); + + describe('supports URLs to visit new panel page', () => { + it.each` + path | currentDashboard + ${'/panel/new'} | ${undefined} + ${'/dashboard.yml/panel/new'} | ${'dashboard.yml'} + ${'/config/prometheus/common_metrics.yml/panel/new'} | ${'config/prometheus/common_metrics.yml'} + ${'/config%2Fprometheus%2Fcommon_metrics.yml/panel/new'} | ${'config/prometheus/common_metrics.yml'} + `('"$path" renders page with dashboard "$currentDashboard"', ({ path, currentDashboard }) => { + const wrapper = createWrapper(BASE_PATH, path); + + expect(wrapper.vm.$route.params.dashboard).toBe(currentDashboard); + expect(wrapper.find(PanelNewPage).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index 22f2b2e3c77..5c7ab4e6a1f 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -1,10 +1,11 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import { backoffMockImplementation } from 'jest/helpers/backoff_helper'; import Tracking from '~/tracking'; import axios from '~/lib/utils/axios_utils'; import statusCodes from '~/lib/utils/http_status'; import * as commonUtils from '~/lib/utils/common_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { defaultTimeRange } from '~/vue_shared/constants'; import * as getters from '~/monitoring/stores/getters'; import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; @@ -30,6 +31,7 @@ import { duplicateSystemDashboard, updateVariablesAndFetchData, fetchVariableMetricLabelValues, + fetchPanelPreview, } from '~/monitoring/stores/actions'; import { gqClient, @@ -73,19 +75,7 @@ describe('Monitoring store actions', () => { 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)); - const next = () => callback(next, stop); - // Define a timeout based on a mock timer - setTimeout(() => { - callback(next, stop); - }); - }); - // Run all resolved promises in chain - jest.runOnlyPendingTimers(); - return q; - }); + jest.spyOn(commonUtils, 'backOff').mockImplementation(backoffMockImplementation); }); afterEach(() => { @@ -483,7 +473,6 @@ describe('Monitoring store actions', () => { ], [], () => { - expect(mock.history.get).toHaveLength(1); done(); }, ).catch(done.fail); @@ -569,46 +558,8 @@ describe('Monitoring store actions', () => { }); }); - it('commits result, when waiting for results', done => { - // Mock multiple attempts while the cache is filling up - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).reply(200, { data }); // 4th attempt - - testAction( - fetchPrometheusMetric, - { metric, defaultQueryParams }, - state, - [ - { - type: types.REQUEST_METRIC_RESULT, - payload: { - metricId: metric.metricId, - }, - }, - { - type: types.RECEIVE_METRIC_RESULT_SUCCESS, - payload: { - metricId: metric.metricId, - data, - }, - }, - ], - [], - () => { - expect(mock.history.get).toHaveLength(4); - done(); - }, - ).catch(done.fail); - }); - it('commits failure, when waiting for results and getting a server error', done => { - // Mock multiple attempts while the cache is filling up and fails - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).reply(500); // 4th attempt + mock.onGet(prometheusEndpointPath).reply(500); const error = new Error('Request failed with status code 500'); @@ -633,7 +584,6 @@ describe('Monitoring store actions', () => { ], [], ).catch(e => { - expect(mock.history.get).toHaveLength(4); expect(e).toEqual(error); done(); }); @@ -1205,4 +1155,69 @@ describe('Monitoring store actions', () => { ); }); }); + + describe('fetchPanelPreview', () => { + const panelPreviewEndpoint = '/builder.json'; + const mockYmlContent = 'mock yml content'; + + beforeEach(() => { + state.panelPreviewEndpoint = panelPreviewEndpoint; + }); + + it('should not commit or dispatch if payload is empty', () => { + testAction(fetchPanelPreview, '', state, [], []); + }); + + it('should store the panel and fetch metric results', () => { + const mockPanel = { + title: 'Go heap size', + type: 'area-chart', + }; + + mock + .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }) + .reply(statusCodes.OK, mockPanel); + + testAction( + fetchPanelPreview, + mockYmlContent, + state, + [ + { type: types.SET_PANEL_PREVIEW_IS_SHOWN, payload: true }, + { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, + { type: types.RECEIVE_PANEL_PREVIEW_SUCCESS, payload: mockPanel }, + ], + [{ type: 'fetchPanelPreviewMetrics' }], + ); + }); + + it('should display a validation error when the backend cannot process the yml', () => { + const mockErrorMsg = 'Each "metric" must define one of :query or :query_range'; + + mock + .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }) + .reply(statusCodes.UNPROCESSABLE_ENTITY, { + message: mockErrorMsg, + }); + + testAction(fetchPanelPreview, mockYmlContent, state, [ + { type: types.SET_PANEL_PREVIEW_IS_SHOWN, payload: true }, + { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, + { type: types.RECEIVE_PANEL_PREVIEW_FAILURE, payload: mockErrorMsg }, + ]); + }); + + it('should display a generic error when the backend fails', () => { + mock.onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }).reply(500); + + testAction(fetchPanelPreview, mockYmlContent, state, [ + { type: types.SET_PANEL_PREVIEW_IS_SHOWN, payload: true }, + { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, + { + type: types.RECEIVE_PANEL_PREVIEW_FAILURE, + payload: 'Request failed with status code 500', + }, + ]); + }); + }); }); diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index a69f5265ea7..509de8a4596 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -11,37 +11,36 @@ import { storeVariables, mockLinks, } from '../mock_data'; -import { - metricsDashboardPayload, - metricResultStatus, - metricResultPods, - metricResultEmpty, -} from '../fixture_data'; +import { metricsDashboardPayload } from '../fixture_data'; describe('Monitoring store Getters', () => { + let state; + + const getMetric = ({ group = 0, panel = 0, metric = 0 } = {}) => + state.dashboard.panelGroups[group].panels[panel].metrics[metric]; + + const setMetricSuccess = ({ group, panel, metric, result = metricsResult } = {}) => { + const { metricId } = getMetric({ group, panel, metric }); + mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, { + metricId, + data: { + resultType: 'matrix', + result, + }, + }); + }; + + const setMetricFailure = ({ group, panel, metric } = {}) => { + const { metricId } = getMetric({ group, panel, metric }); + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { + metricId, + }); + }; + describe('getMetricStates', () => { let setupState; - let state; let getMetricStates; - const setMetricSuccess = ({ result = metricsResult, group = 0, panel = 0, metric = 0 }) => { - const { metricId } = state.dashboard.panelGroups[group].panels[panel].metrics[metric]; - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, { - metricId, - data: { - resultType: 'matrix', - result, - }, - }); - }; - - const setMetricFailure = ({ group = 0, panel = 0, metric = 0 }) => { - const { metricId } = state.dashboard.panelGroups[group].panels[panel].metrics[metric]; - mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { - metricId, - }); - }; - beforeEach(() => { setupState = (initState = {}) => { state = initState; @@ -81,7 +80,7 @@ describe('Monitoring store Getters', () => { it('on an empty metric with no result, returns NO_DATA', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - setMetricSuccess({ result: [], group: 2 }); + setMetricSuccess({ group: 2, result: [] }); expect(getMetricStates()).toEqual([metricStates.NO_DATA]); }); @@ -147,7 +146,6 @@ describe('Monitoring store Getters', () => { describe('metricsWithData', () => { let metricsWithData; let setupState; - let state; beforeEach(() => { setupState = (initState = {}) => { @@ -191,35 +189,39 @@ describe('Monitoring store Getters', () => { it('an empty metric, returns empty', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultEmpty); + setMetricSuccess({ result: [] }); expect(metricsWithData()).toEqual([]); }); it('a metric with results, it returns a metric', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultStatus); + setMetricSuccess(); - expect(metricsWithData()).toEqual([metricResultStatus.metricId]); + expect(metricsWithData()).toEqual([getMetric().metricId]); }); it('multiple metrics with results, it return multiple metrics', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultStatus); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultPods); + setMetricSuccess({ panel: 0 }); + setMetricSuccess({ panel: 1 }); - expect(metricsWithData()).toEqual([metricResultStatus.metricId, metricResultPods.metricId]); + expect(metricsWithData()).toEqual([ + getMetric({ panel: 0 }).metricId, + getMetric({ panel: 1 }).metricId, + ]); }); it('multiple metrics with results, it returns metrics filtered by group', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultStatus); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultPods); + + setMetricSuccess({ group: 1 }); + setMetricSuccess({ group: 1, panel: 1 }); // First group has metrics expect(metricsWithData(state.dashboard.panelGroups[1].key)).toEqual([ - metricResultStatus.metricId, - metricResultPods.metricId, + getMetric({ group: 1 }).metricId, + getMetric({ group: 1, panel: 1 }).metricId, ]); // Second group has no metrics @@ -229,7 +231,6 @@ describe('Monitoring store Getters', () => { }); describe('filteredEnvironments', () => { - let state; const setupState = (initState = {}) => { state = { ...state, @@ -284,7 +285,6 @@ describe('Monitoring store Getters', () => { describe('metricsSavedToDb', () => { let metricsSavedToDb; - let state; let mockData; beforeEach(() => { @@ -335,8 +335,6 @@ describe('Monitoring store Getters', () => { }); describe('getCustomVariablesParams', () => { - let state; - beforeEach(() => { state = { variables: {}, @@ -367,58 +365,65 @@ describe('Monitoring store Getters', () => { describe('selectedDashboard', () => { const { selectedDashboard } = getters; - const localGetters = state => ({ - fullDashboardPath: getters.fullDashboardPath(state), + const localGetters = localState => ({ + fullDashboardPath: getters.fullDashboardPath(localState), }); it('returns a dashboard', () => { - const state = { + const localState = { allDashboards: dashboardGitResponse, currentDashboard: dashboardGitResponse[0].path, customDashboardBasePath, }; - expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(localState, localGetters(localState))).toEqual( + dashboardGitResponse[0], + ); }); - it('returns a non-default dashboard', () => { - const state = { + it('returns a dashboard different from the overview dashboard', () => { + const localState = { allDashboards: dashboardGitResponse, currentDashboard: dashboardGitResponse[1].path, customDashboardBasePath, }; - expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[1]); + expect(selectedDashboard(localState, localGetters(localState))).toEqual( + dashboardGitResponse[1], + ); }); - it('returns a default dashboard when no dashboard is selected', () => { - const state = { + it('returns the overview dashboard when no dashboard is selected', () => { + const localState = { allDashboards: dashboardGitResponse, currentDashboard: null, customDashboardBasePath, }; - expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(localState, localGetters(localState))).toEqual( + dashboardGitResponse[0], + ); }); - it('returns a default dashboard when dashboard cannot be found', () => { - const state = { + it('returns the overview dashboard when dashboard cannot be found', () => { + const localState = { allDashboards: dashboardGitResponse, currentDashboard: 'wrong_path', customDashboardBasePath, }; - expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(localState, localGetters(localState))).toEqual( + dashboardGitResponse[0], + ); }); it('returns null when no dashboards are present', () => { - const state = { + const localState = { allDashboards: [], currentDashboard: dashboardGitResponse[0].path, customDashboardBasePath, }; - expect(selectedDashboard(state, localGetters(state))).toEqual(null); + expect(selectedDashboard(localState, localGetters(localState))).toEqual(null); }); }); describe('linksWithMetadata', () => { - let state; const setupState = (initState = {}) => { state = { ...state, diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 14b38d79aa2..8d1351fc909 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -4,8 +4,8 @@ import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import state from '~/monitoring/stores/state'; import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; - import { deploymentData, dashboardGitResponse, storeTextVariables } from '../mock_data'; +import { prometheusMatrixMultiResult } from '../graph_data'; import { metricsDashboardPayload } from '../fixture_data'; describe('Monitoring mutations', () => { @@ -259,27 +259,6 @@ describe('Monitoring mutations', () => { describe('Individual panel/metric results', () => { const metricId = 'NO_DB_response_metrics_nginx_ingress_throughput_status_code'; - 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]; @@ -307,6 +286,8 @@ describe('Monitoring mutations', () => { }); it('adds results to the store', () => { + const data = prometheusMatrixMultiResult(); + expect(getMetric().result).toBe(null); mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](stateCopy, { @@ -488,4 +469,128 @@ describe('Monitoring mutations', () => { }); }); }); + + describe('REQUEST_PANEL_PREVIEW', () => { + it('saves yml content and resets other preview data', () => { + const mockYmlContent = 'mock yml content'; + mutations[types.REQUEST_PANEL_PREVIEW](stateCopy, mockYmlContent); + + expect(stateCopy.panelPreviewIsLoading).toBe(true); + expect(stateCopy.panelPreviewYml).toBe(mockYmlContent); + expect(stateCopy.panelPreviewGraphData).toBe(null); + expect(stateCopy.panelPreviewError).toBe(null); + }); + }); + + describe('RECEIVE_PANEL_PREVIEW_SUCCESS', () => { + it('saves graph data', () => { + mutations[types.RECEIVE_PANEL_PREVIEW_SUCCESS](stateCopy, { + title: 'My Title', + type: 'area-chart', + }); + + expect(stateCopy.panelPreviewIsLoading).toBe(false); + expect(stateCopy.panelPreviewGraphData).toMatchObject({ + title: 'My Title', + type: 'area-chart', + }); + expect(stateCopy.panelPreviewError).toBe(null); + }); + }); + + describe('RECEIVE_PANEL_PREVIEW_FAILURE', () => { + it('saves graph data', () => { + mutations[types.RECEIVE_PANEL_PREVIEW_FAILURE](stateCopy, 'Error!'); + + expect(stateCopy.panelPreviewIsLoading).toBe(false); + expect(stateCopy.panelPreviewGraphData).toBe(null); + expect(stateCopy.panelPreviewError).toBe('Error!'); + }); + }); + + describe('panel preview metric', () => { + const getPreviewMetricAt = i => stateCopy.panelPreviewGraphData.metrics[i]; + + beforeEach(() => { + stateCopy.panelPreviewGraphData = { + title: 'Preview panel title', + metrics: [ + { + query: 'query', + }, + ], + }; + }); + + describe('REQUEST_PANEL_PREVIEW_METRIC_RESULT', () => { + it('sets the metric to loading for the first time', () => { + mutations[types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](stateCopy, { index: 0 }); + + expect(getPreviewMetricAt(0).loading).toBe(true); + expect(getPreviewMetricAt(0).state).toBe(metricStates.LOADING); + }); + + it('sets the metric to loading and keeps the result', () => { + getPreviewMetricAt(0).result = [[0, 1]]; + getPreviewMetricAt(0).state = metricStates.OK; + + mutations[types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](stateCopy, { index: 0 }); + + expect(getPreviewMetricAt(0)).toMatchObject({ + loading: true, + result: [[0, 1]], + state: metricStates.OK, + }); + }); + }); + + describe('RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS', () => { + it('saves the result in the metric', () => { + const data = prometheusMatrixMultiResult(); + + mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS](stateCopy, { + index: 0, + data, + }); + + expect(getPreviewMetricAt(0)).toMatchObject({ + loading: false, + state: metricStates.OK, + result: expect.any(Array), + }); + expect(getPreviewMetricAt(0).result).toHaveLength(data.result.length); + }); + }); + + describe('RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE', () => { + it('stores an error in the metric', () => { + mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](stateCopy, { + index: 0, + }); + + expect(getPreviewMetricAt(0).loading).toBe(false); + expect(getPreviewMetricAt(0).state).toBe(metricStates.UNKNOWN_ERROR); + expect(getPreviewMetricAt(0).result).toBe(null); + + expect(getPreviewMetricAt(0)).toMatchObject({ + loading: false, + result: null, + state: metricStates.UNKNOWN_ERROR, + }); + }); + + it('stores a timeout error in a metric', () => { + mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](stateCopy, { + index: 0, + error: { message: 'BACKOFF_TIMEOUT' }, + }); + + expect(getPreviewMetricAt(0)).toMatchObject({ + loading: false, + result: null, + state: metricStates.TIMEOUT, + }); + }); + }); + }); }); diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js index 35ca6ba9b52..fd7d09f7f72 100644 --- a/spec/frontend/monitoring/utils_spec.js +++ b/spec/frontend/monitoring/utils_spec.js @@ -1,6 +1,6 @@ +import { TEST_HOST } from 'jest/helpers/test_constants'; import * as monitoringUtils from '~/monitoring/utils'; import * as urlUtils from '~/lib/utils/url_utility'; -import { TEST_HOST } from 'jest/helpers/test_constants'; import { mockProjectDir, barMockData } from './mock_data'; import { singleStatGraphData, anomalyGraphData } from './graph_data'; import { metricsDashboardViewModel, graphData } from './fixture_data'; |