diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-24 03:08:44 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-24 03:08:44 +0000 |
commit | 4870899d6cec693b58acbef91636e1310160fa28 (patch) | |
tree | 305628a2a8436e77b0c3972f8053df771a89843c | |
parent | 082b24b03bbb9dca7edf1341aa7b0a51c9aeb18b (diff) | |
download | gitlab-ce-4870899d6cec693b58acbef91636e1310160fa28.tar.gz |
Add latest changes from gitlab-org/gitlab@master
23 files changed, 678 insertions, 218 deletions
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index b6f53ee0d69..3fc3dcf6d01 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -2,7 +2,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash from '~/flash'; import { - GlDeprecatedButton, + GlButton, GlFormInput, GlLink, GlLoadingIcon, @@ -33,7 +33,7 @@ const SENTRY_TIMEOUT = 10000; export default { components: { - GlDeprecatedButton, + GlButton, GlFormInput, GlLink, GlLoadingIcon, @@ -279,22 +279,24 @@ export default { </div> <div class="error-details-actions"> <div class="d-inline-flex bv-d-sm-down-none"> - <gl-deprecated-button + <gl-button :loading="updatingIgnoreStatus" data-testid="update-ignore-status-btn" @click="onIgnoreStatusUpdate" > {{ ignoreBtnLabel }} - </gl-deprecated-button> - <gl-deprecated-button - class="btn-outline-info ml-2" + </gl-button> + <gl-button + class="ml-2" + category="secondary" + variant="info" :loading="updatingResolveStatus" data-testid="update-resolve-status-btn" @click="onResolveStatusUpdate" > {{ resolveBtnLabel }} - </gl-deprecated-button> - <gl-deprecated-button + </gl-button> + <gl-button v-if="error.gitlabIssuePath" class="ml-2" data-testid="view_issue_button" @@ -302,7 +304,7 @@ export default { variant="success" > {{ __('View issue') }} - </gl-deprecated-button> + </gl-button> <form ref="sentryIssueForm" :action="projectIssuesPath" @@ -317,15 +319,16 @@ export default { name="issue[sentry_issue_attributes][sentry_issue_identifier]" /> <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" /> - <gl-deprecated-button + <gl-button v-if="!error.gitlabIssuePath" - class="btn-success" + category="primary" + variant="success" :loading="issueCreationInProgress" data-qa-selector="create_issue_button" @click="createIssue" > {{ __('Create issue') }} - </gl-deprecated-button> + </gl-button> </form> </div> <gl-dropdown diff --git a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue index 0ac7c0b80df..d79b8284a65 100644 --- a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue +++ b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue @@ -22,13 +22,13 @@ export default { default: '', }, options: { - type: Array, + type: Object, required: true, }, }, computed: { - defaultText() { - const selectedOpt = this.options.find(opt => opt.value === this.value); + text() { + const selectedOpt = this.options.values?.find(opt => opt.value === this.value); return selectedOpt?.text || this.value; }, }, @@ -41,10 +41,13 @@ export default { </script> <template> <gl-form-group :label="label"> - <gl-dropdown toggle-class="dropdown-menu-toggle" :text="defaultText"> - <gl-dropdown-item v-for="(opt, key) in options" :key="key" @click="onUpdate(opt.value)">{{ - opt.text - }}</gl-dropdown-item> + <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')"> + <gl-dropdown-item + v-for="val in options.values" + :key="val.value" + @click="onUpdate(val.value)" + >{{ val.text }}</gl-dropdown-item + > </gl-dropdown> </gl-form-group> </template> diff --git a/app/assets/javascripts/monitoring/components/variables/text_variable.vue b/app/assets/javascripts/monitoring/components/variables/text_field.vue index ce0d19760e2..ce0d19760e2 100644 --- a/app/assets/javascripts/monitoring/components/variables/text_variable.vue +++ b/app/assets/javascripts/monitoring/components/variables/text_field.vue diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue index 3d1d111d5b3..9d3159dfb6e 100644 --- a/app/assets/javascripts/monitoring/components/variables_section.vue +++ b/app/assets/javascripts/monitoring/components/variables_section.vue @@ -1,13 +1,14 @@ <script> import { mapState, mapActions } from 'vuex'; -import CustomVariable from './variables/custom_variable.vue'; -import TextVariable from './variables/text_variable.vue'; +import DropdownField from './variables/dropdown_field.vue'; +import TextField from './variables/text_field.vue'; import { setCustomVariablesFromUrl } from '../utils'; +import { VARIABLE_TYPES } from '../constants'; export default { components: { - CustomVariable, - TextVariable, + DropdownField, + TextField, }, computed: { ...mapState('monitoringDashboard', ['variables']), @@ -27,12 +28,11 @@ export default { setCustomVariablesFromUrl(this.variables); } }, - variableComponent(type) { - const types = { - text: TextVariable, - custom: CustomVariable, - }; - return types[type] || TextVariable; + variableField(type) { + if (type === VARIABLE_TYPES.custom || type === VARIABLE_TYPES.metric_label_values) { + return DropdownField; + } + return TextField; }, }, }; @@ -41,7 +41,7 @@ export default { <div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section"> <div v-for="(variable, key) in variables" :key="key" class="mb-1 pr-2 d-flex d-sm-block"> <component - :is="variableComponent(variable.type)" + :is="variableField(variable.type)" class="mb-0 flex-grow-1" :label="variable.label" :value="variable.value" diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 50330046c99..7cebe5cb11d 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -230,6 +230,7 @@ export const OPERATORS = { export const VARIABLE_TYPES = { custom: 'custom', text: 'text', + metric_label_values: 'metric_label_values', }; /** diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 6527a799759..aaa7865f30a 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -21,6 +21,7 @@ import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE, DEFAULT_DASHBOARD_PATH, + VARIABLE_TYPES, } from '../constants'; function prometheusMetricQueryParams(timeRange) { @@ -191,8 +192,12 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { return Promise.reject(); } + // Time range params must be pre-calculated once for all metrics and options + // A subsequent call, may calculate a different time range const defaultQueryParams = prometheusMetricQueryParams(state.timeRange); + dispatch('fetchVariableMetricLabelValues', { defaultQueryParams }); + const promises = []; state.dashboard.panelGroups.forEach(group => { group.panels.forEach(panel => { @@ -466,10 +471,41 @@ export const duplicateSystemDashboard = ({ state }, payload) => { // Variables manipulation export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => { - commit(types.UPDATE_VARIABLES, updatedVariable); + commit(types.UPDATE_VARIABLE_VALUE, updatedVariable); return dispatch('fetchDashboardData'); }; +export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQueryParams }) => { + const { start_time, end_time } = defaultQueryParams; + const optionsRequests = []; + + Object.entries(state.variables).forEach(([key, variable]) => { + if (variable.type === VARIABLE_TYPES.metric_label_values) { + const { prometheusEndpointPath, label } = variable.options; + + const optionsRequest = backOffRequest(() => + axios.get(prometheusEndpointPath, { + params: { start_time, end_time }, + }), + ) + .then(({ data }) => data.data) + .then(data => { + commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data }); + }) + .catch(() => { + createFlash( + sprintf(s__('Metrics|There was an error getting options for variable "%{name}".'), { + name: key, + }), + ); + }); + optionsRequests.push(optionsRequest); + } + }); + + return Promise.all(optionsRequests); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 17563f966f5..d43065749c1 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -3,7 +3,8 @@ export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD'; export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS'; export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; export const SET_VARIABLES = 'SET_VARIABLES'; -export const UPDATE_VARIABLES = 'UPDATE_VARIABLES'; +export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE'; +export const UPDATE_VARIABLE_METRIC_LABEL_VALUES = 'UPDATE_VARIABLE_METRIC_LABEL_VALUES'; export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING'; export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 08e6f00b866..53c8029e46b 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -2,9 +2,10 @@ import Vue from 'vue'; import { pick } from 'lodash'; import * as types from './mutation_types'; import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils'; +import httpStatusCodes from '~/lib/utils/http_status'; import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; import { endpointKeys, initialStateKeys, metricStates } from '../constants'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { optionsFromSeriesData } from './variable_mapping'; /** * Locate and return a metric in the dashboard by its id @@ -205,10 +206,16 @@ export default { [types.SET_VARIABLES](state, variables) { state.variables = variables; }, - [types.UPDATE_VARIABLES](state, updatedVariable) { - Object.assign(state.variables[updatedVariable.key], { - ...state.variables[updatedVariable.key], - value: updatedVariable.value, + [types.UPDATE_VARIABLE_VALUE](state, { key, value }) { + Object.assign(state.variables[key], { + ...state.variables[key], + value, }); }, + [types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](state, { variable, label, data = [] }) { + const values = optionsFromSeriesData({ label, data }); + + // Add new options with assign to ensure Vue reactivity + Object.assign(variable.options, { values }); + }, }; diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js index c0a8150063b..0b268402992 100644 --- a/app/assets/javascripts/monitoring/stores/variable_mapping.js +++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js @@ -46,7 +46,7 @@ const textAdvancedVariableParser = advTextVar => ({ * @param {Object} custom variable option * @returns {Object} normalized custom variable options */ -const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({ +const normalizeVariableValues = ({ default: defaultOpt = false, text, value }) => ({ default: defaultOpt, text: text || value, value, @@ -59,17 +59,19 @@ const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, val * The default value is the option with default set to true or the first option * if none of the options have default prop true. * - * @param {Object} advVariable advance custom variable + * @param {Object} advVariable advanced custom variable * @returns {Object} */ const customAdvancedVariableParser = advVariable => { - const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions); - const defaultOpt = options.find(opt => opt.default === true) || options[0]; + const values = (advVariable?.options?.values ?? []).map(normalizeVariableValues); + const defaultValue = values.find(opt => opt.default === true) || values[0]; return { type: VARIABLE_TYPES.custom, label: advVariable.label, - value: defaultOpt?.value, - options, + value: defaultValue?.value, + options: { + values, + }, }; }; @@ -80,7 +82,7 @@ const customAdvancedVariableParser = advVariable => { * @param {String} opt option from simple custom variable * @returns {Object} */ -const parseSimpleCustomOptions = opt => ({ text: opt, value: opt }); +export const parseSimpleCustomValues = opt => ({ text: opt, value: opt }); /** * Custom simple variables are rendered as dropdown elements in the dashboard @@ -95,12 +97,28 @@ const parseSimpleCustomOptions = opt => ({ text: opt, value: opt }); * @returns {Object} */ const customSimpleVariableParser = simpleVar => { - const options = (simpleVar || []).map(parseSimpleCustomOptions); + const values = (simpleVar || []).map(parseSimpleCustomValues); return { type: VARIABLE_TYPES.custom, - value: options[0].value, + value: values[0].value, label: null, - options: options.map(normalizeCustomVariableOptions), + options: { + values: values.map(normalizeVariableValues), + }, + }; +}; + +const metricLabelValuesVariableParser = variable => { + const { label, options = {} } = variable; + return { + type: VARIABLE_TYPES.metric_label_values, + value: null, + label, + options: { + prometheusEndpointPath: options.prometheus_endpoint_path || '', + label: options.label || null, + values: [], // values are initially empty + }, }; }; @@ -123,14 +141,16 @@ const isSimpleCustomVariable = customVar => Array.isArray(customVar); * @return {Function} parser method */ const getVariableParser = variable => { - if (isSimpleCustomVariable(variable)) { + if (isString(variable)) { + return textSimpleVariableParser; + } else if (isSimpleCustomVariable(variable)) { return customSimpleVariableParser; - } else if (variable.type === VARIABLE_TYPES.custom) { - return customAdvancedVariableParser; } else if (variable.type === VARIABLE_TYPES.text) { return textAdvancedVariableParser; - } else if (isString(variable)) { - return textSimpleVariableParser; + } else if (variable.type === VARIABLE_TYPES.custom) { + return customAdvancedVariableParser; + } else if (variable.type === VARIABLE_TYPES.metric_label_values) { + return metricLabelValuesVariableParser; } return () => null; }; @@ -200,4 +220,67 @@ export const mergeURLVariables = (varsFromYML = {}) => { return variables; }; +/** + * Converts series data to options that can be added to a + * variable. Series data is returned from the Prometheus API + * `/api/v1/series`. + * + * Finds a `label` in the series data, so it can be used as + * a filter. + * + * For example, for the arguments: + * + * { + * "label": "job" + * "data" : [ + * { + * "__name__" : "up", + * "job" : "prometheus", + * "instance" : "localhost:9090" + * }, + * { + * "__name__" : "up", + * "job" : "node", + * "instance" : "localhost:9091" + * }, + * { + * "__name__" : "process_start_time_seconds", + * "job" : "prometheus", + * "instance" : "localhost:9090" + * } + * ] + * } + * + * It returns all the different "job" values: + * + * [ + * { + * "label": "node", + * "value": "node" + * }, + * { + * "label": "prometheus", + * "value": "prometheus" + * } + * ] + * + * @param {options} options object + * @param {options.seriesLabel} name of the searched series label + * @param {options.data} series data from the series API + * @return {array} Options objects with the shape `{ label, value }` + * + * @see https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers + */ +export const optionsFromSeriesData = ({ label, data = [] }) => { + const optionsSet = data.reduce((set, seriesObject) => { + // Use `new Set` to deduplicate options + if (seriesObject[label]) { + set.add(seriesObject[label]); + } + return set; + }, new Set()); + + return [...optionsSet].map(parseSimpleCustomValues); +}; + export default {}; diff --git a/app/models/repository.rb b/app/models/repository.rb index 838b850ddcb..aef11936ca0 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1113,7 +1113,7 @@ class Repository def project if repo_type.snippet? container.project - else + elsif container.is_a?(Project) container end end diff --git a/changelogs/unreleased/214539-fe-fetch-dynamic-variable-options.yml b/changelogs/unreleased/214539-fe-fetch-dynamic-variable-options.yml new file mode 100644 index 00000000000..44c49d2c3e3 --- /dev/null +++ b/changelogs/unreleased/214539-fe-fetch-dynamic-variable-options.yml @@ -0,0 +1,5 @@ +--- +title: Fetch metrics dashboard templating variable options using a Prometheus query +merge_request: 34607 +author: +type: added diff --git a/doc/development/geo/framework.md b/doc/development/geo/framework.md index 40190108b7a..debd9978c62 100644 --- a/doc/development/geo/framework.md +++ b/doc/development/geo/framework.md @@ -511,6 +511,13 @@ Widgets should now be verified by Geo! Individual widget synchronization and verification data should now be available via the GraphQL API! +1. Take care of replicating "update" events. Geo Framework does not currently support + replicating "update" events because all entities added to the framework, by this time, + are immutable. If this is the case + for the entity you're going to add, please follow [https://gitlab.com/gitlab-org/gitlab/-/issues/118743] + and [https://gitlab.com/gitlab-org/gitlab/-/issues/118745] as examples to add the new event type. + Please also remove this notice when you've added it. + #### Admin UI To do: This should be done as part of diff --git a/doc/development/telemetry/usage_ping.md b/doc/development/telemetry/usage_ping.md index 1c7b9f46bf4..30fc7e192d2 100644 --- a/doc/development/telemetry/usage_ping.md +++ b/doc/development/telemetry/usage_ping.md @@ -326,7 +326,15 @@ When adding, changing, or updating metrics, please update the [Usage Statistics Check if new metrics need to be added to the Versions Application. See `usage_data` [schema](https://gitlab.com/gitlab-services/version-gitlab-com/-/blob/master/db/schema.rb#L147) and usage data [parameters accepted](https://gitlab.com/gitlab-services/version-gitlab-com/-/blob/master/app/services/usage_ping.rb). Any metrics added under the `counts` key are saved in the `counts` column. -### 6. Ask for a Telemetry Review +### 6. Add the feature label + +Add the `feature` label to the Merge Request for new Usage Ping metrics. These are user-facing changes and are part of expanding the Usage Ping feature. + +### 7. Add a changelog file + +Ensure you comply with the [Changelog entries guide](../changelog.md). + +### 8. Ask for a Telemetry Review On GitLab.com, we have DangerBot setup to monitor Telemetry related files and DangerBot will recommend a Telemetry review. Mention `@gitlab-org/growth/telemetry/engineers` in your MR for a review. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b837fd415fe..e791fd771d7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14308,6 +14308,9 @@ msgstr "" msgid "Metrics|Refresh dashboard" msgstr "" +msgid "Metrics|Select a value" +msgstr "" + msgid "Metrics|Star dashboard" msgstr "" @@ -14335,6 +14338,9 @@ msgstr "" msgid "Metrics|There was an error getting environments information." msgstr "" +msgid "Metrics|There was an error getting options for variable \"%{name}\"." +msgstr "" + msgid "Metrics|There was an error trying to validate your query" msgstr "" diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index f10102ef799..6124602e038 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -3,7 +3,7 @@ import Vuex from 'vuex'; import { __ } from '~/locale'; import createFlash from '~/flash'; import { - GlDeprecatedButton, + GlButton, GlLoadingIcon, GlLink, GlBadge, @@ -52,7 +52,7 @@ describe('ErrorDetails', () => { function mountComponent() { wrapper = shallowMount(ErrorDetails, { - stubs: { GlDeprecatedButton, GlSprintf }, + stubs: { GlButton, GlSprintf }, localVue, store, mocks, @@ -195,7 +195,7 @@ describe('ErrorDetails', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.find(Stacktrace).exists()).toBe(false); expect(wrapper.find(GlBadge).exists()).toBe(false); - expect(wrapper.findAll(GlDeprecatedButton).length).toBe(3); + expect(wrapper.findAll(GlButton)).toHaveLength(3); }); describe('unsafe chars for culprit field', () => { diff --git a/spec/frontend/monitoring/components/variables/custom_variable_spec.js b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js index 5a2b26219b6..623125d18f1 100644 --- a/spec/frontend/monitoring/components/variables/custom_variable_spec.js +++ b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js @@ -1,18 +1,25 @@ import { shallowMount } from '@vue/test-utils'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import CustomVariable from '~/monitoring/components/variables/custom_variable.vue'; +import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; describe('Custom variable component', () => { let wrapper; - const propsData = { + + const defaultProps = { name: 'env', label: 'Select environment', value: 'Production', - options: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }], + options: { + values: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }], + }, }; - const createShallowWrapper = () => { - wrapper = shallowMount(CustomVariable, { - propsData, + + const createShallowWrapper = props => { + wrapper = shallowMount(DropdownField, { + propsData: { + ...defaultProps, + ...props, + }, }); }; @@ -22,19 +29,25 @@ describe('Custom variable component', () => { it('renders dropdown element when all necessary props are passed', () => { createShallowWrapper(); - expect(findDropdown()).toExist(); + expect(findDropdown().exists()).toBe(true); }); it('renders dropdown element with a text', () => { createShallowWrapper(); - expect(findDropdown().attributes('text')).toBe(propsData.value); + expect(findDropdown().attributes('text')).toBe(defaultProps.value); }); it('renders all the dropdown items', () => { createShallowWrapper(); - expect(findDropdownItems()).toHaveLength(propsData.options.length); + expect(findDropdownItems()).toHaveLength(defaultProps.options.values.length); + }); + + it('renders dropdown when values are missing', () => { + createShallowWrapper({ options: {} }); + + expect(findDropdown().exists()).toBe(true); }); it('changing dropdown items triggers update', () => { diff --git a/spec/frontend/monitoring/components/variables/text_variable_spec.js b/spec/frontend/monitoring/components/variables/text_field_spec.js index f01584ae8bc..68bfb8ec695 100644 --- a/spec/frontend/monitoring/components/variables/text_variable_spec.js +++ b/spec/frontend/monitoring/components/variables/text_field_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlFormInput } from '@gitlab/ui'; -import TextVariable from '~/monitoring/components/variables/text_variable.vue'; +import TextField from '~/monitoring/components/variables/text_field.vue'; describe('Text variable component', () => { let wrapper; @@ -10,7 +10,7 @@ describe('Text variable component', () => { value: 'test-pod', }; const createShallowWrapper = () => { - wrapper = shallowMount(TextVariable, { + wrapper = shallowMount(TextField, { propsData, }); }; diff --git a/spec/frontend/monitoring/components/variables_section_spec.js b/spec/frontend/monitoring/components/variables_section_spec.js index fd814e81c8f..9fb93a18e0b 100644 --- a/spec/frontend/monitoring/components/variables_section_spec.js +++ b/spec/frontend/monitoring/components/variables_section_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import VariablesSection from '~/monitoring/components/variables_section.vue'; -import CustomVariable from '~/monitoring/components/variables/custom_variable.vue'; -import TextVariable from '~/monitoring/components/variables/text_variable.vue'; +import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; +import TextField from '~/monitoring/components/variables/text_field.vue'; import { updateHistory, mergeUrlParams } from '~/lib/utils/url_utility'; import { createStore } from '~/monitoring/stores'; import { convertVariablesForURL } from '~/monitoring/utils'; @@ -21,6 +21,7 @@ describe('Metrics dashboard/variables section component', () => { label1: mockTemplatingDataResponses.simpleText.simpleText, label2: mockTemplatingDataResponses.advText.advText, label3: mockTemplatingDataResponses.simpleCustom.simpleCustom, + label4: mockTemplatingDataResponses.metricLabelValues.simple, }; const createShallowWrapper = () => { @@ -29,8 +30,8 @@ describe('Metrics dashboard/variables section component', () => { }); }; - const findTextInput = () => wrapper.findAll(TextVariable); - const findCustomInput = () => wrapper.findAll(CustomVariable); + const findTextInputs = () => wrapper.findAll(TextField); + const findCustomInputs = () => wrapper.findAll(DropdownField); beforeEach(() => { store = createStore(); @@ -40,20 +41,30 @@ describe('Metrics dashboard/variables section component', () => { it('does not show the variables section', () => { createShallowWrapper(); - const allInputs = findTextInput().length + findCustomInput().length; + const allInputs = findTextInputs().length + findCustomInputs().length; expect(allInputs).toBe(0); }); - it('shows the variables section', () => { - createShallowWrapper(); - store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables); + describe('when variables are set', () => { + beforeEach(() => { + createShallowWrapper(); + store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables); + return wrapper.vm.$nextTick; + }); - return wrapper.vm.$nextTick(() => { - const allInputs = findTextInput().length + findCustomInput().length; + it('shows the variables section', () => { + const allInputs = findTextInputs().length + findCustomInputs().length; expect(allInputs).toBe(Object.keys(sampleVariables).length); }); + + it('shows the right custom variable inputs', () => { + const customInputs = findCustomInputs(); + + expect(customInputs.at(0).props('name')).toBe('label3'); + expect(customInputs.at(1).props('name')).toBe('label4'); + }); }); describe('when changing the variable inputs', () => { @@ -79,7 +90,7 @@ describe('Metrics dashboard/variables section component', () => { }); it('merges the url params and refreshes the dashboard when a text-based variables inputs are updated', () => { - const firstInput = findTextInput().at(0); + const firstInput = findTextInputs().at(0); firstInput.vm.$emit('onUpdate', 'label1', 'test'); @@ -94,7 +105,7 @@ describe('Metrics dashboard/variables section component', () => { }); it('merges the url params and refreshes the dashboard when a custom-based variables inputs are updated', () => { - const firstInput = findCustomInput().at(0); + const firstInput = findCustomInputs().at(0); firstInput.vm.$emit('onUpdate', 'label1', 'test'); @@ -109,7 +120,7 @@ describe('Metrics dashboard/variables section component', () => { }); it('does not merge the url params and refreshes the dashboard if the value entered is not different that is what currently stored', () => { - const firstInput = findTextInput().at(0); + const firstInput = findTextInputs().at(0); firstInput.vm.$emit('onUpdate', 'label1', 'Simple text'); diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 0f443838985..6a0a3eeb5ae 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -717,6 +717,17 @@ const templatingVariableTypes = { }, }, }, + metricLabelValues: { + simple: { + label: 'Metric Label Values', + type: 'metric_label_values', + options: { + prometheus_endpoint_path: '/series', + series_selector: 'backend:haproxy_backend_availability:ratio{env="{{env}}"}', + label: 'backend', + }, + }, + }, }; const generateMockTemplatingData = data => { @@ -754,23 +765,25 @@ const responseForSimpleCustomVariable = { simpleCustom: { label: 'simpleCustom', value: 'value1', - options: [ - { - default: false, - text: 'value1', - value: 'value1', - }, - { - default: false, - text: 'value2', - value: 'value2', - }, - { - default: false, - text: 'value3', - value: 'value3', - }, - ], + options: { + values: [ + { + default: false, + text: 'value1', + value: 'value1', + }, + { + default: false, + text: 'value2', + value: 'value2', + }, + { + default: false, + text: 'value3', + value: 'value3', + }, + ], + }, type: 'custom', }, }; @@ -778,7 +791,9 @@ const responseForSimpleCustomVariable = { const responseForAdvancedCustomVariableWithoutOptions = { advCustomWithoutOpts: { label: 'advCustomWithoutOpts', - options: [], + options: { + values: [], + }, type: 'custom', }, }; @@ -787,18 +802,20 @@ const responseForAdvancedCustomVariableWithoutLabel = { advCustomWithoutLabel: { label: 'advCustomWithoutLabel', value: 'value2', - options: [ - { - default: false, - text: 'Var 1 Option 1', - value: 'value1', - }, - { - default: true, - text: 'Var 1 Option 2', - value: 'value2', - }, - ], + options: { + values: [ + { + default: false, + text: 'Var 1 Option 1', + value: 'value1', + }, + { + default: true, + text: 'Var 1 Option 2', + value: 'value2', + }, + ], + }, type: 'custom', }, }; @@ -807,39 +824,56 @@ const responseForAdvancedCustomVariableWithoutOptText = { advCustomWithoutOptText: { label: 'Options without text', value: 'value2', - options: [ - { - default: false, - text: 'value1', - value: 'value1', - }, - { - default: true, - text: 'value2', - value: 'value2', - }, - ], + options: { + values: [ + { + default: false, + text: 'value1', + value: 'value1', + }, + { + default: true, + text: 'value2', + value: 'value2', + }, + ], + }, type: 'custom', }, }; +const responseForMetricLabelValues = { + simple: { + label: 'Metric Label Values', + type: 'metric_label_values', + value: null, + options: { + prometheusEndpointPath: '/series', + label: 'backend', + values: [], + }, + }, +}; + const responseForAdvancedCustomVariable = { ...responseForSimpleCustomVariable, advCustomNormal: { label: 'Advanced Var', value: 'value2', - options: [ - { - default: false, - text: 'Var 1 Option 1', - value: 'value1', - }, - { - default: true, - text: 'Var 1 Option 2', - value: 'value2', - }, - ], + options: { + values: [ + { + default: false, + text: 'Var 1 Option 1', + value: 'value1', + }, + { + default: true, + text: 'Var 1 Option 2', + value: 'value2', + }, + ], + }, type: 'custom', }, }; @@ -873,6 +907,9 @@ export const mockTemplatingData = { simpleCustom: templatingVariableTypes.custom.simple, advCustomNormal: templatingVariableTypes.custom.advanced.normal, }), + metricLabelValues: generateMockTemplatingData({ + simple: templatingVariableTypes.metricLabelValues.simple, + }), allVariableTypes: generateMockTemplatingData({ simpleText: templatingVariableTypes.text.simple, advText: templatingVariableTypes.text.advanced, @@ -893,4 +930,5 @@ export const mockTemplatingDataResponses = { advCustomWithoutOptText: responseForAdvancedCustomVariableWithoutOptText, simpleAndAdv: responseForAdvancedCustomVariable, allVariableTypes: responsesForAllVariableTypes, + metricLabelValues: responseForMetricLabelValues, }; diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index ac925222924..5af13c0987a 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -29,6 +29,7 @@ import { toggleStarredValue, duplicateSystemDashboard, updateVariablesAndFetchData, + fetchVariableMetricLabelValues, } from '~/monitoring/stores/actions'; import { gqClient, @@ -384,14 +385,22 @@ describe('Monitoring store actions', () => { value: 0, }, ); - expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); + expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { + defaultQueryParams: { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }, + }); expect(createFlash).not.toHaveBeenCalled(); done(); }) .catch(done.fail); }); + it('dispatches fetchPrometheusMetric for each panel query', done => { state.dashboard.panelGroups = convertObjectPropsToCamelCase( metricsDashboardResponse.dashboard.panel_groups, @@ -434,21 +443,27 @@ describe('Monitoring store actions', () => { const metric = state.dashboard.panelGroups[0].panels[0].metrics[0]; dispatch.mockResolvedValueOnce(); // fetchDeploymentsData + dispatch.mockResolvedValueOnce(); // fetchVariableMetricLabelValues // Mock having one out of four metrics failing dispatch.mockRejectedValueOnce(new Error('Error fetching this metric')); dispatch.mockResolvedValue(); fetchDashboardData({ state, commit, dispatch }) .then(() => { - expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 1); // plus 1 for deployments + const defaultQueryParams = { + start_time: expect.any(String), + end_time: expect.any(String), + step: expect.any(Number), + }; + + expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); + expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', { + defaultQueryParams, + }); expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { metric, - defaultQueryParams: { - start_time: expect.any(String), - end_time: expect.any(String), - step: expect.any(Number), - }, + defaultQueryParams, }); expect(createFlash).toHaveBeenCalledTimes(1); @@ -1116,14 +1131,14 @@ describe('Monitoring store actions', () => { // Variables manipulation describe('updateVariablesAndFetchData', () => { - it('should commit UPDATE_VARIABLES mutation and fetch data', done => { + it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', done => { testAction( updateVariablesAndFetchData, { pod: 'POD' }, state, [ { - type: types.UPDATE_VARIABLES, + type: types.UPDATE_VARIABLE_VALUE, payload: { pod: 'POD' }, }, ], @@ -1136,4 +1151,72 @@ describe('Monitoring store actions', () => { ); }); }); + + describe('fetchVariableMetricLabelValues', () => { + const variable = { + type: 'metric_label_values', + options: { + prometheusEndpointPath: '/series', + label: 'job', + }, + }; + const defaultQueryParams = { + start_time: '2019-08-06T12:40:02.184Z', + end_time: '2019-08-06T20:40:02.184Z', + }; + + beforeEach(() => { + state = { + ...state, + timeRange: defaultTimeRange, + variables: { + label1: variable, + }, + }; + }); + + it('should commit UPDATE_VARIABLE_METRIC_LABEL_VALUES mutation and fetch data', () => { + const data = [ + { + __name__: 'up', + job: 'prometheus', + }, + { + __name__: 'up', + job: 'POD', + }, + ]; + + mock.onGet('/series').reply(200, { + status: 'success', + data, + }); + + return testAction( + fetchVariableMetricLabelValues, + { defaultQueryParams }, + state, + [ + { + type: types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, + payload: { variable, label: 'job', data }, + }, + ], + [], + ); + }); + + it('should notify the user that dynamic options were not loaded', () => { + mock.onGet('/series').reply(500); + + return testAction(fetchVariableMetricLabelValues, { defaultQueryParams }, state, [], []).then( + () => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + expect.stringContaining('error getting options for variable "label1"'), + ); + }, + ); + }); + }); }); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 111ee69f4e6..fb5e6156daf 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -441,16 +441,57 @@ describe('Monitoring mutations', () => { }); }); - describe('UPDATE_VARIABLES', () => { + describe('UPDATE_VARIABLE_VALUE', () => { afterEach(() => { mutations[types.SET_VARIABLES](stateCopy, {}); }); it('updates only the value of the variable in variables', () => { mutations[types.SET_VARIABLES](stateCopy, { environment: { value: 'prod', type: 'text' } }); - mutations[types.UPDATE_VARIABLES](stateCopy, { key: 'environment', value: 'new prod' }); + mutations[types.UPDATE_VARIABLE_VALUE](stateCopy, { key: 'environment', value: 'new prod' }); expect(stateCopy.variables).toEqual({ environment: { value: 'new prod', type: 'text' } }); }); }); + + describe('UPDATE_VARIABLE_METRIC_LABEL_VALUES', () => { + it('updates options in a variable', () => { + const data = [ + { + __name__: 'up', + job: 'prometheus', + env: 'prd', + }, + { + __name__: 'up', + job: 'prometheus', + env: 'stg', + }, + { + __name__: 'up', + job: 'node', + env: 'prod', + }, + { + __name__: 'up', + job: 'node', + env: 'stg', + }, + ]; + + const variable = { + options: {}, + }; + + mutations[types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](stateCopy, { + variable, + label: 'job', + data, + }); + + expect(variable.options).toEqual({ + values: [{ text: 'prometheus', value: 'prometheus' }, { text: 'node', value: 'node' }], + }); + }); + }); }); diff --git a/spec/frontend/monitoring/store/variable_mapping_spec.js b/spec/frontend/monitoring/store/variable_mapping_spec.js index 5164ed1b54b..390cb2d8eac 100644 --- a/spec/frontend/monitoring/store/variable_mapping_spec.js +++ b/spec/frontend/monitoring/store/variable_mapping_spec.js @@ -1,94 +1,185 @@ -import { parseTemplatingVariables, mergeURLVariables } from '~/monitoring/stores/variable_mapping'; +import { + parseTemplatingVariables, + mergeURLVariables, + optionsFromSeriesData, +} from '~/monitoring/stores/variable_mapping'; import * as urlUtils from '~/lib/utils/url_utility'; import { mockTemplatingData, mockTemplatingDataResponses } from '../mock_data'; -describe('parseTemplatingVariables', () => { - it.each` - case | input | expected - ${'Returns empty object for no dashboard input'} | ${{}} | ${{}} - ${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}} - ${'Returns empty object for empty templating prop'} | ${mockTemplatingData.emptyTemplatingProp} | ${{}} - ${'Returns empty object for empty variables prop'} | ${mockTemplatingData.emptyVariablesProp} | ${{}} - ${'Returns parsed object for simple text variable'} | ${mockTemplatingData.simpleText} | ${mockTemplatingDataResponses.simpleText} - ${'Returns parsed object for advanced text variable'} | ${mockTemplatingData.advText} | ${mockTemplatingDataResponses.advText} - ${'Returns parsed object for simple custom variable'} | ${mockTemplatingData.simpleCustom} | ${mockTemplatingDataResponses.simpleCustom} - ${'Returns parsed object for advanced custom variable without options'} | ${mockTemplatingData.advCustomWithoutOpts} | ${mockTemplatingDataResponses.advCustomWithoutOpts} - ${'Returns parsed object for advanced custom variable for option without text'} | ${mockTemplatingData.advCustomWithoutOptText} | ${mockTemplatingDataResponses.advCustomWithoutOptText} - ${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}} - ${'Returns parsed object for advanced custom variable without label'} | ${mockTemplatingData.advCustomWithoutLabel} | ${mockTemplatingDataResponses.advCustomWithoutLabel} - ${'Returns parsed object for simple and advanced custom variables'} | ${mockTemplatingData.simpleAndAdv} | ${mockTemplatingDataResponses.simpleAndAdv} - ${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes} - `('$case', ({ input, expected }) => { - expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected); +describe('Monitoring variable mapping', () => { + describe('parseTemplatingVariables', () => { + it.each` + case | input | expected + ${'Returns empty object for no dashboard input'} | ${{}} | ${{}} + ${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}} + ${'Returns empty object for empty templating prop'} | ${mockTemplatingData.emptyTemplatingProp} | ${{}} + ${'Returns empty object for empty variables prop'} | ${mockTemplatingData.emptyVariablesProp} | ${{}} + ${'Returns parsed object for simple text variable'} | ${mockTemplatingData.simpleText} | ${mockTemplatingDataResponses.simpleText} + ${'Returns parsed object for advanced text variable'} | ${mockTemplatingData.advText} | ${mockTemplatingDataResponses.advText} + ${'Returns parsed object for simple custom variable'} | ${mockTemplatingData.simpleCustom} | ${mockTemplatingDataResponses.simpleCustom} + ${'Returns parsed object for advanced custom variable without options'} | ${mockTemplatingData.advCustomWithoutOpts} | ${mockTemplatingDataResponses.advCustomWithoutOpts} + ${'Returns parsed object for advanced custom variable for option without text'} | ${mockTemplatingData.advCustomWithoutOptText} | ${mockTemplatingDataResponses.advCustomWithoutOptText} + ${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}} + ${'Returns parsed object for advanced custom variable without label'} | ${mockTemplatingData.advCustomWithoutLabel} | ${mockTemplatingDataResponses.advCustomWithoutLabel} + ${'Returns parsed object for simple and advanced custom variables'} | ${mockTemplatingData.simpleAndAdv} | ${mockTemplatingDataResponses.simpleAndAdv} + ${'Returns parsed object for metricLabelValues'} | ${mockTemplatingData.metricLabelValues} | ${mockTemplatingDataResponses.metricLabelValues} + ${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes} + `('$case', ({ input, expected }) => { + expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected); + }); }); -}); -describe('mergeURLVariables', () => { - beforeEach(() => { - jest.spyOn(urlUtils, 'queryToObject'); - }); + describe('mergeURLVariables', () => { + beforeEach(() => { + jest.spyOn(urlUtils, 'queryToObject'); + }); - afterEach(() => { - urlUtils.queryToObject.mockRestore(); - }); + afterEach(() => { + urlUtils.queryToObject.mockRestore(); + }); - it('returns empty object if variables are not defined in yml or URL', () => { - urlUtils.queryToObject.mockReturnValueOnce({}); + it('returns empty object if variables are not defined in yml or URL', () => { + urlUtils.queryToObject.mockReturnValueOnce({}); - expect(mergeURLVariables({})).toEqual({}); - }); + expect(mergeURLVariables({})).toEqual({}); + }); + + it('returns empty object if variables are defined in URL but not in yml', () => { + urlUtils.queryToObject.mockReturnValueOnce({ + 'var-env': 'one', + 'var-instance': 'localhost', + }); - it('returns empty object if variables are defined in URL but not in yml', () => { - urlUtils.queryToObject.mockReturnValueOnce({ - 'var-env': 'one', - 'var-instance': 'localhost', + expect(mergeURLVariables({})).toEqual({}); }); - expect(mergeURLVariables({})).toEqual({}); - }); + it('returns yml variables if variables defined in yml but not in the URL', () => { + urlUtils.queryToObject.mockReturnValueOnce({}); - it('returns yml variables if variables defined in yml but not in the URL', () => { - urlUtils.queryToObject.mockReturnValueOnce({}); + const params = { + env: 'one', + instance: 'localhost', + }; - const params = { - env: 'one', - instance: 'localhost', - }; + expect(mergeURLVariables(params)).toEqual(params); + }); - expect(mergeURLVariables(params)).toEqual(params); - }); + it('returns yml variables if variables defined in URL do not match with yml variables', () => { + const urlParams = { + 'var-env': 'one', + 'var-instance': 'localhost', + }; + const ymlParams = { + pod: { value: 'one' }, + service: { value: 'database' }, + }; + urlUtils.queryToObject.mockReturnValueOnce(urlParams); + + expect(mergeURLVariables(ymlParams)).toEqual(ymlParams); + }); + + it('returns merged yml and URL variables if there is some match', () => { + const urlParams = { + 'var-env': 'one', + 'var-instance': 'localhost:8080', + }; + const ymlParams = { + instance: { value: 'localhost' }, + service: { value: 'database' }, + }; + + const merged = { + instance: { value: 'localhost:8080' }, + service: { value: 'database' }, + }; + + urlUtils.queryToObject.mockReturnValueOnce(urlParams); - it('returns yml variables if variables defined in URL do not match with yml variables', () => { - const urlParams = { - 'var-env': 'one', - 'var-instance': 'localhost', - }; - const ymlParams = { - pod: { value: 'one' }, - service: { value: 'database' }, - }; - urlUtils.queryToObject.mockReturnValueOnce(urlParams); - - expect(mergeURLVariables(ymlParams)).toEqual(ymlParams); + expect(mergeURLVariables(ymlParams)).toEqual(merged); + }); }); - it('returns merged yml and URL variables if there is some match', () => { - const urlParams = { - 'var-env': 'one', - 'var-instance': 'localhost:8080', - }; - const ymlParams = { - instance: { value: 'localhost' }, - service: { value: 'database' }, - }; + describe('optionsFromSeriesData', () => { + it('fetches the label values from missing data', () => { + expect(optionsFromSeriesData({ label: 'job' })).toEqual([]); + }); - const merged = { - instance: { value: 'localhost:8080' }, - service: { value: 'database' }, - }; + it('fetches the label values from a simple series', () => { + const data = [ + { + __name__: 'up', + job: 'job1', + }, + { + __name__: 'up', + job: 'job2', + }, + ]; + + expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ + { text: 'job1', value: 'job1' }, + { text: 'job2', value: 'job2' }, + ]); + }); - urlUtils.queryToObject.mockReturnValueOnce(urlParams); + it('fetches the label values from multiple series', () => { + const data = [ + { + __name__: 'up', + job: 'job1', + instance: 'host1', + }, + { + __name__: 'up', + job: 'job2', + instance: 'host1', + }, + { + __name__: 'up', + job: 'job1', + instance: 'host2', + }, + { + __name__: 'up', + job: 'job2', + instance: 'host2', + }, + ]; + + expect(optionsFromSeriesData({ label: '__name__', data })).toEqual([ + { text: 'up', value: 'up' }, + ]); + + expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ + { text: 'job1', value: 'job1' }, + { text: 'job2', value: 'job2' }, + ]); + + expect(optionsFromSeriesData({ label: 'instance', data })).toEqual([ + { text: 'host1', value: 'host1' }, + { text: 'host2', value: 'host2' }, + ]); + }); - expect(mergeURLVariables(ymlParams)).toEqual(merged); + it('fetches the label values from a series with missing values', () => { + const data = [ + { + __name__: 'up', + job: 'job1', + }, + { + __name__: 'up', + job: 'job2', + }, + { + __name__: 'up', + }, + ]; + + expect(optionsFromSeriesData({ label: 'job', data })).toEqual([ + { text: 'job1', value: 'job1' }, + { text: 'job2', value: 'job2' }, + ]); + }); }); }); diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index e2a148165ab..a9021e5771c 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -2895,6 +2895,29 @@ describe Repository do end end + describe '#project' do + it 'returns the project for a project snippet' do + snippet = create(:project_snippet) + + expect(snippet.repository.project).to be(snippet.project) + end + + it 'returns nil for a personal snippet' do + snippet = create(:personal_snippet) + + expect(snippet.repository.project).to be_nil + end + + it 'returns the container if it is a project' do + expect(repository.project).to be(project) + end + + it 'returns nil if the container is not a project' do + expect(repository).to receive(:container).and_return(Group.new) + expect(repository.project).to be_nil + end + end + describe '#submodule_links' do it 'returns an instance of Gitlab::SubmoduleLinks' do expect(repository.submodule_links).to be_a(Gitlab::SubmoduleLinks) |