diff options
32 files changed, 711 insertions, 48 deletions
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue new file mode 100644 index 00000000000..175e89a454b --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue @@ -0,0 +1,93 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSearchBoxByType, + GlIcon, +} from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; +import { mapGetters } from 'vuex'; + +export default { + name: 'CiEnvironmentsDropdown', + components: { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSearchBoxByType, + GlIcon, + }, + props: { + value: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + searchTerm: this.value || '', + }; + }, + computed: { + ...mapGetters(['joinedEnvironments']), + composedCreateButtonLabel() { + return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm }); + }, + shouldRenderCreateButton() { + return this.searchTerm && !this.joinedEnvironments.includes(this.searchTerm); + }, + filteredResults() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.joinedEnvironments.filter(resultString => + resultString.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + }, + watch: { + value(newVal) { + this.searchTerm = newVal; + }, + }, + methods: { + selectEnvironment(selected) { + this.$emit('selectEnvironment', selected); + this.searchTerm = ''; + }, + createClicked() { + this.$emit('createClicked', this.searchTerm); + this.searchTerm = ''; + }, + isSelected(env) { + return this.value === env; + }, + }, +}; +</script> +<template> + <gl-dropdown :text="value"> + <gl-search-box-by-type v-model.trim="searchTerm" class="m-2" /> + <gl-dropdown-item + v-for="environment in filteredResults" + :key="environment" + @click="selectEnvironment(environment)" + > + <gl-icon + :class="{ invisible: !isSelected(environment) }" + name="mobile-issue-close" + class="vertical-align-middle" + /> + {{ environment }} + </gl-dropdown-item> + <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ + __('No matching results') + }}</gl-dropdown-item> + <template v-if="shouldRenderCreateButton"> + <gl-dropdown-divider /> + <gl-dropdown-item @click="createClicked"> + {{ composedCreateButtonLabel }} + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 0ccc58ec2da..0460181558b 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -2,6 +2,7 @@ import { __ } from '~/locale'; import { mapActions, mapState } from 'vuex'; import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; +import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { GlButton, GlModal, @@ -17,6 +18,7 @@ import { export default { modalId: ADD_CI_VARIABLE_MODAL_ID, components: { + CiEnvironmentsDropdown, GlButton, GlModal, GlFormSelect, @@ -36,6 +38,7 @@ export default { 'variableBeingEdited', 'isGroup', 'maskableRegex', + 'selectedEnvironment', ]), canSubmit() { if (this.variableData.masked && this.maskedState === false) { @@ -80,6 +83,10 @@ export default { 'displayInputValue', 'clearModal', 'deleteVariable', + 'setEnvironmentScope', + 'addWildCardScope', + 'resetSelectedEnvironment', + 'setSelectedEnvironment', ]), updateOrAddVariable() { if (this.variableBeingEdited) { @@ -95,6 +102,7 @@ export default { } else { this.clearModal(); } + this.resetSelectedEnvironment(); }, hideModal() { this.$refs.modal.hide(); @@ -158,10 +166,11 @@ export default { label-for="ci-variable-env" class="w-50" > - <gl-form-select - id="ci-variable-env" - v-model="variableData.environment_scope" - :options="environments" + <ci-environments-dropdown + class="w-100" + :value="variableData.environment_scope" + @selectEnvironment="setEnvironmentScope" + @createClicked="addWildCardScope" /> </gl-form-group> </div> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index 3f2f89ada6f..806fa3e1191 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -92,6 +92,7 @@ export default { sort-by="key" sort-direction="asc" stacked="lg" + table-class="text-secondary" fixed show-empty sort-icon-left diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index b2fa980c546..d22138db102 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -6,7 +6,7 @@ export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable'; export const displayText = { variableText: __('Var'), fileText: __('File'), - allEnvironmentsText: __('All'), + allEnvironmentsText: __('All (default)'), }; export const types = { diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js index f3a629b84ee..a22fa03e16d 100644 --- a/app/assets/javascripts/ci_variable_list/store/actions.js +++ b/app/assets/javascripts/ci_variable_list/store/actions.js @@ -153,3 +153,22 @@ export const fetchEnvironments = ({ dispatch, state }) => { createFlash(__('There was an error fetching the environments information.')); }); }; + +export const setEnvironmentScope = ({ commit, dispatch }, environment) => { + commit(types.SET_ENVIRONMENT_SCOPE, environment); + dispatch('setSelectedEnvironment', environment); +}; + +export const addWildCardScope = ({ commit, dispatch }, environment) => { + commit(types.ADD_WILD_CARD_SCOPE, environment); + commit(types.SET_ENVIRONMENT_SCOPE, environment); + dispatch('setSelectedEnvironment', environment); +}; + +export const resetSelectedEnvironment = ({ commit }) => { + commit(types.RESET_SELECTED_ENVIRONMENT); +}; + +export const setSelectedEnvironment = ({ commit }, environment) => { + commit(types.SET_SELECTED_ENVIRONMENT, environment); +}; diff --git a/app/assets/javascripts/ci_variable_list/store/getters.js b/app/assets/javascripts/ci_variable_list/store/getters.js new file mode 100644 index 00000000000..14b728302f9 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/store/getters.js @@ -0,0 +1,9 @@ +/* eslint-disable import/prefer-default-export */ +// Disabling import/prefer-default-export can be +// removed once a second getter is added to this file +import { uniq } from 'lodash'; + +export const joinedEnvironments = state => { + const scopesFromVariables = (state.variables || []).map(variable => variable.environment_scope); + return uniq(state.environments.concat(scopesFromVariables)).sort(); +}; diff --git a/app/assets/javascripts/ci_variable_list/store/index.js b/app/assets/javascripts/ci_variable_list/store/index.js index db4ba95b3c2..83802f6a36f 100644 --- a/app/assets/javascripts/ci_variable_list/store/index.js +++ b/app/assets/javascripts/ci_variable_list/store/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; import mutations from './mutations'; import state from './state'; @@ -10,6 +11,7 @@ export default (initialState = {}) => new Vuex.Store({ actions, mutations, + getters, state: { ...state(), ...initialState, diff --git a/app/assets/javascripts/ci_variable_list/store/mutation_types.js b/app/assets/javascripts/ci_variable_list/store/mutation_types.js index 240066d0f22..0b41c20bce7 100644 --- a/app/assets/javascripts/ci_variable_list/store/mutation_types.js +++ b/app/assets/javascripts/ci_variable_list/store/mutation_types.js @@ -20,3 +20,7 @@ export const RECEIVE_UPDATE_VARIABLE_ERROR = 'RECEIVE_UPDATE_VARIABLE_ERROR'; export const REQUEST_ENVIRONMENTS = 'REQUEST_ENVIRONMENTS'; export const RECEIVE_ENVIRONMENTS_SUCCESS = 'RECEIVE_ENVIRONMENTS_SUCCESS'; +export const SET_ENVIRONMENT_SCOPE = 'SET_ENVIRONMENT_SCOPE'; +export const ADD_WILD_CARD_SCOPE = 'ADD_WILD_CARD_SCOPE'; +export const RESET_SELECTED_ENVIRONMENT = 'RESET_SELECTED_ENVIRONMENT'; +export const SET_SELECTED_ENVIRONMENT = 'SET_SELECTED_ENVIRONMENT'; diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js index c75eb4a91fd..7ee7d7bdc26 100644 --- a/app/assets/javascripts/ci_variable_list/store/mutations.js +++ b/app/assets/javascripts/ci_variable_list/store/mutations.js @@ -83,4 +83,25 @@ export default { state.variableBeingEdited = null; state.showInputValue = false; }, + + [types.SET_ENVIRONMENT_SCOPE](state, environment) { + if (state.variableBeingEdited) { + state.variableBeingEdited.environment_scope = environment; + } else { + state.variable.environment_scope = environment; + } + }, + + [types.ADD_WILD_CARD_SCOPE](state, environment) { + state.environments.push(environment); + state.environments.sort(); + }, + + [types.RESET_SELECTED_ENVIRONMENT](state) { + state.selectedEnvironment = ''; + }, + + [types.SET_SELECTED_ENVIRONMENT](state, environment) { + state.selectedEnvironment = environment; + }, }; diff --git a/app/assets/javascripts/ci_variable_list/store/state.js b/app/assets/javascripts/ci_variable_list/store/state.js index 5166321d6a7..8c0b9c6966f 100644 --- a/app/assets/javascripts/ci_variable_list/store/state.js +++ b/app/assets/javascripts/ci_variable_list/store/state.js @@ -21,4 +21,5 @@ export default () => ({ environments: [], typeOptions: [displayText.variableText, displayText.fileText], variableBeingEdited: null, + selectedEnvironment: '', }); diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js index 5272778ce2d..df3c5c2132a 100644 --- a/app/assets/javascripts/droplab/utils.js +++ b/app/assets/javascripts/droplab/utils.js @@ -1,6 +1,6 @@ /* eslint-disable */ -import { template as _template } from 'underscore'; +import { template as _template } from 'lodash'; import { DATA_TRIGGER, DATA_DROPDOWN, TEMPLATE_REGEX } from './constants'; const utils = { diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 77ba17b6e68..44e38089da8 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -101,7 +101,8 @@ export default { return this.graphData.title || ''; }, alertWidgetAvailable() { - return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData; + // This method is extended by ee functionality + return false; }, graphDataHasMetrics() { return ( @@ -209,7 +210,7 @@ export default { > <div class="d-flex align-items-center"> <alert-widget - v-if="alertWidgetAvailable && graphData" + v-if="alertWidgetAvailable" :modal-id="`alert-modal-${index}`" :alerts-endpoint="alertsEndpoint" :relevant-queries="graphData.metrics" diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 3990a8d1f61..6609946e02e 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -104,3 +104,8 @@ export const endpointKeys = [ * as Vue props. */ export const initialStateKeys = [...endpointKeys, 'currentEnvironmentName']; + +/** + * Constant to indicate if a metric exists in the database + */ +export const NOT_IN_DB_PREFIX = 'NO_DB'; diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 86f416240c8..2e4987b7349 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -144,7 +144,7 @@ export const fetchPrometheusMetric = ({ commit }, { metric, params }) => { const minStep = 60; const queryDataPoints = 600; - const step = Math.max(minStep, Math.ceil(timeDiff / queryDataPoints)); + const step = metric.step ? metric.step : Math.max(minStep, Math.ceil(timeDiff / queryDataPoints)); const queryParams = { start_time, diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index 1affc6f0a76..a6d80c5063e 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -1,3 +1,5 @@ +import { NOT_IN_DB_PREFIX } from '../constants'; + const metricsIdsInPanel = panel => panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); @@ -59,6 +61,29 @@ export const metricsWithData = state => groupKey => { }; /** + * Metrics loaded from project-defined dashboards do not have a metric_id. + * This getter checks which metrics are stored in the db (have a metric id) + * This is hopefully a temporary solution until BE processes metrics before passing to FE + * + * Related: + * https://gitlab.com/gitlab-org/gitlab/-/issues/28241 + * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27447 + */ +export const metricsSavedToDb = state => { + const metricIds = []; + state.dashboard.panelGroups.forEach(({ panels }) => { + panels.forEach(({ metrics }) => { + const metricIdsInDb = metrics + .filter(({ metricId }) => !metricId.startsWith(NOT_IN_DB_PREFIX)) + .map(({ metricId }) => metricId); + + metricIds.push(...metricIdsInDb); + }); + }); + return metricIds; +}; + +/** * Filter environments by names. * * This is used in the environments dropdown with searchable input. diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index b5938fb1205..5e620d6c2f5 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -2,6 +2,7 @@ import { slugify } from '~/lib/utils/text_utility'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { NOT_IN_DB_PREFIX } from '../constants'; export const gqClient = createGqClient( {}, @@ -14,11 +15,18 @@ export const gqClient = createGqClient( * Metrics loaded from project-defined dashboards do not have a metric_id. * This method creates a unique ID combining metric_id and id, if either is present. * This is hopefully a temporary solution until BE processes metrics before passing to FE + * + * Related: + * https://gitlab.com/gitlab-org/gitlab/-/issues/28241 + * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27447 + * * @param {Object} metric - metric + * @param {Number} metric.metric_id - Database metric id + * @param {String} metric.id - User-defined identifier * @returns {Object} - normalized metric with a uniqueID */ // eslint-disable-next-line babel/camelcase -export const uniqMetricsId = ({ metric_id, id }) => `${metric_id}_${id}`; +export const uniqMetricsId = ({ metric_id, id }) => `${metric_id || NOT_IN_DB_PREFIX}_${id}`; /** * Project path has a leading slash that doesn't work well diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index 505b51c2006..517c22e2334 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -15,7 +15,7 @@ class FileUploader < GitlabUploader prepend ObjectStorage::Extension::RecordsUploads MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}.freeze - DYNAMIC_PATH_PATTERN = %r{.*(?<secret>\h{32})/(?<identifier>.*)}.freeze + DYNAMIC_PATH_PATTERN = %r{.*/(?<secret>\h{10,32})/(?<identifier>.*)}.freeze VALID_SECRET_PATTERN = %r{\A\h{10,32}\z}.freeze InvalidSecret = Class.new(StandardError) diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml index 8c1c1d41caa..9e7990ef8ca 100644 --- a/app/views/admin/serverless/domains/_form.html.haml +++ b/app/views/admin/serverless/domains/_form.html.haml @@ -66,3 +66,34 @@ = _("Upload a private key for your certificate") = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "btn btn-success js-serverless-domain-submit", disabled: @domain.persisted? + - if @domain.persisted? + %button.btn.btn-remove{ type: 'button', data: { toggle: 'modal', target: "#modal-delete-domain" } } + = _('Delete domain') + +-# haml-lint:disable NoPlainNodes +- if @domain.persisted? + - domain_attached = @domain.serverless_domain_clusters.count > 0 + .modal{ id: "modal-delete-domain", tabindex: -1 } + .modal-dialog + .modal-content + .modal-header + %h3.page-title= _('Delete serverless domain?') + %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } + %span{ "aria-hidden": true } × + + .modal-body + - if domain_attached + = _("You must disassociate %{domain} from all clusters it is attached to before deletion.").html_safe % { domain: "<code>#{@domain.domain}</code>".html_safe } + - else + = _("You are about to delete %{domain} from your instance. This domain will no longer be available to any Knative application.").html_safe % { domain: "<code>#{@domain.domain}</code>".html_safe } + + .modal-footer + %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } + = _('Cancel') + + = link_to _('Delete domain'), + admin_serverless_domain_path(@domain.id), + title: _('Delete'), + method: :delete, + class: "btn btn-remove", + disabled: domain_attached diff --git a/changelogs/unreleased/209940-geo-fails-to-sync-file-uploads-with-improper-formatted-path.yml b/changelogs/unreleased/209940-geo-fails-to-sync-file-uploads-with-improper-formatted-path.yml new file mode 100644 index 00000000000..521fc9f83b7 --- /dev/null +++ b/changelogs/unreleased/209940-geo-fails-to-sync-file-uploads-with-improper-formatted-path.yml @@ -0,0 +1,5 @@ +--- +title: Fix incorrect regex used in FileUploader#extract_dynamic_path +merge_request: 28683 +author: +type: fixed diff --git a/changelogs/unreleased/rc-use_metric_step.yml b/changelogs/unreleased/rc-use_metric_step.yml new file mode 100644 index 00000000000..f697523997c --- /dev/null +++ b/changelogs/unreleased/rc-use_metric_step.yml @@ -0,0 +1,5 @@ +--- +title: Allow defining of metric step in dashboard yml +merge_request: 28247 +author: +type: added diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 97e224c0fe9..7dbdf40d831 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1631,6 +1631,9 @@ msgstr "" msgid "All %{replicableType} are being scheduled for %{action}" msgstr "" +msgid "All (default)" +msgstr "" + msgid "All Members" msgstr "" @@ -5904,6 +5907,9 @@ msgstr "" msgid "Create project label" msgstr "" +msgid "Create wildcard: %{searchTerm}" +msgstr "" + msgid "Create your first page" msgstr "" @@ -6446,6 +6452,9 @@ msgstr "" msgid "Delete comment" msgstr "" +msgid "Delete domain" +msgstr "" + msgid "Delete license" msgstr "" @@ -6458,6 +6467,9 @@ msgstr "" msgid "Delete project" msgstr "" +msgid "Delete serverless domain?" +msgstr "" + msgid "Delete snippet" msgstr "" @@ -23079,6 +23091,9 @@ msgstr "" msgid "You" msgstr "" +msgid "You are about to delete %{domain} from your instance. This domain will no longer be available to any Knative application." +msgstr "" + msgid "You are about to transfer the control of your account to %{group_name} group. This action is NOT reversible, you won't be able to access any of your groups and projects outside of %{group_name} once this transfer is complete." msgstr "" @@ -23355,6 +23370,9 @@ msgstr "" msgid "You must accept our Terms of Service and privacy policy in order to register an account" msgstr "" +msgid "You must disassociate %{domain} from all clusters it is attached to before deletion." +msgstr "" + msgid "You must have maintainer access to force delete a lock" msgstr "" diff --git a/spec/features/admin/admin_serverless_domains_spec.rb b/spec/features/admin/admin_serverless_domains_spec.rb index 85fe67004da..48f6af8d4bd 100644 --- a/spec/features/admin/admin_serverless_domains_spec.rb +++ b/spec/features/admin/admin_serverless_domains_spec.rb @@ -56,4 +56,32 @@ describe 'Admin Serverless Domains', :js do expect(page).to have_content 'Domain was successfully updated' expect(page).to have_content '/CN=test-certificate' end + + context 'when domain exists' do + let!(:domain) { create(:pages_domain, :instance_serverless) } + + it 'Displays a modal when attempting to delete a domain' do + visit admin_serverless_domains_path + + click_button 'Delete domain' + + page.within '#modal-delete-domain' do + expect(page).to have_content "You are about to delete #{domain.domain} from your instance." + expect(page).to have_link('Delete domain') + end + end + + it 'Displays a modal with disabled button if unable to delete a domain' do + create(:serverless_domain_cluster, pages_domain: domain) + + visit admin_serverless_domains_path + + click_button 'Delete domain' + + page.within '#modal-delete-domain' do + expect(page).to have_content "You must disassociate #{domain.domain} from all clusters it is attached to before deletion." + expect(page).to have_link('Delete domain') + end + end + end end diff --git a/spec/frontend/ci_variable_list/components/ci_enviroments_dropdown_spec.js b/spec/frontend/ci_variable_list/components/ci_enviroments_dropdown_spec.js new file mode 100644 index 00000000000..a52b38599f7 --- /dev/null +++ b/spec/frontend/ci_variable_list/components/ci_enviroments_dropdown_spec.js @@ -0,0 +1,103 @@ +import Vuex from 'vuex'; +import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlDropdownItem, GlIcon } from '@gitlab/ui'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Ci environments dropdown', () => { + let wrapper; + let store; + + const createComponent = term => { + store = new Vuex.Store({ + getters: { + joinedEnvironments: () => ['dev', 'prod', 'staging'], + }, + }); + + wrapper = shallowMount(CiEnvironmentsDropdown, { + store, + localVue, + propsData: { + value: term, + }, + }); + }; + + const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findDropdownItemByIndex = index => wrapper.findAll(GlDropdownItem).at(index); + const findActiveIconByIndex = index => wrapper.findAll(GlIcon).at(index); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('No enviroments found', () => { + beforeEach(() => { + createComponent('stable'); + }); + + it('renders create button with search term if enviroments do not contain search term', () => { + expect(findAllDropdownItems()).toHaveLength(2); + expect(findDropdownItemByIndex(1).text()).toBe('Create wildcard: stable'); + }); + + it('renders empty results message', () => { + expect(findDropdownItemByIndex(0).text()).toBe('No matching results'); + }); + }); + + describe('Search term is empty', () => { + beforeEach(() => { + createComponent(''); + }); + + it('renders all enviroments when search term is empty', () => { + expect(findAllDropdownItems()).toHaveLength(3); + expect(findDropdownItemByIndex(0).text()).toBe('dev'); + expect(findDropdownItemByIndex(1).text()).toBe('prod'); + expect(findDropdownItemByIndex(2).text()).toBe('staging'); + }); + }); + + describe('Enviroments found', () => { + beforeEach(() => { + createComponent('prod'); + }); + + it('renders only the enviroment searched for', () => { + expect(findAllDropdownItems()).toHaveLength(1); + expect(findDropdownItemByIndex(0).text()).toBe('prod'); + }); + + it('should not display create button', () => { + const enviroments = findAllDropdownItems().filter(env => env.text().startsWith('Create')); + expect(enviroments).toHaveLength(0); + expect(findAllDropdownItems()).toHaveLength(1); + }); + + it('should not display empty results message', () => { + expect(wrapper.find({ ref: 'noMatchingResults' }).exists()).toBe(false); + }); + + it('should display active checkmark if active', () => { + expect(findActiveIconByIndex(0).classes('invisible')).toBe(false); + }); + + describe('Custom events', () => { + it('should emit selectEnvironment if an environment is clicked', () => { + findDropdownItemByIndex(0).vm.$emit('click'); + expect(wrapper.emitted('selectEnvironment')).toEqual([['prod']]); + }); + + it('should emit createClicked if an environment is clicked', () => { + createComponent('newscope'); + findDropdownItemByIndex(1).vm.$emit('click'); + expect(wrapper.emitted('createClicked')).toEqual([['newscope']]); + }); + }); + }); +}); diff --git a/spec/frontend/ci_variable_list/services/mock_data.js b/spec/frontend/ci_variable_list/services/mock_data.js index 5e0fa55a20c..09c6cd9de21 100644 --- a/spec/frontend/ci_variable_list/services/mock_data.js +++ b/spec/frontend/ci_variable_list/services/mock_data.js @@ -1,7 +1,7 @@ export default { mockVariables: [ { - environment_scope: 'All environments', + environment_scope: 'All (default)', id: 113, key: 'test_var', masked: false, @@ -37,7 +37,7 @@ export default { mockVariablesDisplay: [ { - environment_scope: 'All', + environment_scope: 'All (default)', id: 113, key: 'test_var', masked: false, @@ -47,7 +47,7 @@ export default { variable_type: 'Var', }, { - environment_scope: 'All', + environment_scope: 'All (default)', id: 114, key: 'test_var_2', masked: false, @@ -88,4 +88,67 @@ export default { ozakE+8p06BpxegR4BR3FMHf6p+0jQxUEAkAyb/mVgm66TyghDGC6/YkiKoZptXQ 98TwDIK/39WEB/V607As+KoYazQG8drorw== -----END CERTIFICATE REQUEST-----`, + + mockVariableScopes: [ + { + id: 13, + key: 'test_var_1', + value: 'test_val_1', + variable_type: 'File', + protected: true, + masked: true, + environment_scope: 'All (default)', + secret_value: 'test_val_1', + }, + { + id: 28, + key: 'goku_var', + value: 'goku_val', + variable_type: 'Var', + protected: true, + masked: true, + environment_scope: 'staging', + secret_value: 'goku_val', + }, + { + id: 25, + key: 'test_var_4', + value: 'test_val_4', + variable_type: 'Var', + protected: false, + masked: false, + environment_scope: 'production', + secret_value: 'test_val_4', + }, + { + id: 14, + key: 'test_var_2', + value: 'test_val_2', + variable_type: 'File', + protected: false, + masked: false, + environment_scope: 'staging', + secret_value: 'test_val_2', + }, + { + id: 24, + key: 'test_var_3', + value: 'test_val_3', + variable_type: 'Var', + protected: false, + masked: false, + environment_scope: 'All (default)', + secret_value: 'test_val_3', + }, + { + id: 26, + key: 'test_var_5', + value: 'test_val_5', + variable_type: 'Var', + protected: false, + masked: false, + environment_scope: 'production', + secret_value: 'test_val_5', + }, + ], }; diff --git a/spec/frontend/ci_variable_list/store/getters_spec.js b/spec/frontend/ci_variable_list/store/getters_spec.js new file mode 100644 index 00000000000..7ad96545652 --- /dev/null +++ b/spec/frontend/ci_variable_list/store/getters_spec.js @@ -0,0 +1,21 @@ +import * as getters from '~/ci_variable_list/store/getters'; +import mockData from '../services/mock_data'; + +describe('Ci variable getters', () => { + describe('joinedEnvironments', () => { + it('should join fetched enviroments with variable environment scopes', () => { + const state = { + environments: ['All (default)', 'staging', 'deployment', 'prod'], + variables: mockData.mockVariableScopes, + }; + + expect(getters.joinedEnvironments(state)).toEqual([ + 'All (default)', + 'deployment', + 'prod', + 'production', + 'staging', + ]); + }); + }); +}); diff --git a/spec/frontend/ci_variable_list/store/mutations_spec.js b/spec/frontend/ci_variable_list/store/mutations_spec.js index 05513edff7b..8652359f3df 100644 --- a/spec/frontend/ci_variable_list/store/mutations_spec.js +++ b/spec/frontend/ci_variable_list/store/mutations_spec.js @@ -4,6 +4,15 @@ import * as types from '~/ci_variable_list/store/mutation_types'; describe('CI variable list mutations', () => { let stateCopy; + const variableBeingEdited = { + environment_scope: '*', + id: 63, + key: 'test_var', + masked: false, + protected: false, + value: 'test_val', + variable_type: 'env_var', + }; beforeEach(() => { stateCopy = state(); @@ -21,16 +30,6 @@ describe('CI variable list mutations', () => { describe('VARIABLE_BEING_EDITED', () => { it('should set variable that is being edited', () => { - const variableBeingEdited = { - environment_scope: '*', - id: 63, - key: 'test_var', - masked: false, - protected: false, - value: 'test_val', - variable_type: 'env_var', - }; - mutations[types.VARIABLE_BEING_EDITED](stateCopy, variableBeingEdited); expect(stateCopy.variableBeingEdited).toEqual(variableBeingEdited); @@ -53,7 +52,7 @@ describe('CI variable list mutations', () => { secret_value: '', protected: false, masked: false, - environment_scope: 'All', + environment_scope: 'All (default)', }; mutations[types.CLEAR_MODAL](stateCopy); @@ -61,4 +60,41 @@ describe('CI variable list mutations', () => { expect(stateCopy.variable).toEqual(modalState); }); }); + + describe('RECEIVE_ENVIRONMENTS_SUCCESS', () => { + it('should set environments', () => { + const environments = ['env1', 'env2']; + + mutations[types.RECEIVE_ENVIRONMENTS_SUCCESS](stateCopy, environments); + + expect(stateCopy.environments).toEqual(['All (default)', 'env1', 'env2']); + }); + }); + + describe('SET_ENVIRONMENT_SCOPE', () => { + const environment = 'production'; + + it('should set scope to variable being updated if updating variable', () => { + stateCopy.variableBeingEdited = variableBeingEdited; + + mutations[types.SET_ENVIRONMENT_SCOPE](stateCopy, environment); + + expect(stateCopy.variableBeingEdited.environment_scope).toBe('production'); + }); + + it('should set scope to variable if adding new variable', () => { + mutations[types.SET_ENVIRONMENT_SCOPE](stateCopy, environment); + + expect(stateCopy.variable.environment_scope).toBe('production'); + }); + }); + + describe('ADD_WILD_CARD_SCOPE', () => { + it('should add wild card scope to enviroments array and sort', () => { + stateCopy.environments = ['dev', 'staging']; + mutations[types.ADD_WILD_CARD_SCOPE](stateCopy, 'production'); + + expect(stateCopy.environments).toEqual(['dev', 'production', 'staging']); + }); + }); }); diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 58693723624..dde47178c1d 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -288,7 +288,7 @@ export const mockedEmptyResult = { }; export const mockedEmptyThroughputResult = { - metricId: 'undefined_response_metrics_nginx_ingress_16_throughput_status_code', + metricId: 'NO_DB_response_metrics_nginx_ingress_16_throughput_status_code', result: [], }; @@ -304,12 +304,12 @@ export const mockedQueryResultPayloadCoresTotal = { export const mockedQueryResultFixture = { // First metric in fixture `metrics_dashboard/environment_metrics_dashboard.json` - metricId: 'undefined_response_metrics_nginx_ingress_throughput_status_code', + metricId: 'NO_DB_response_metrics_nginx_ingress_throughput_status_code', result: metricsResult, }; export const mockedQueryResultFixtureStatusCode = { - metricId: 'undefined_response_metrics_nginx_ingress_latency_pod_average', + metricId: 'NO_DB_response_metrics_nginx_ingress_latency_pod_average', result: metricsResult, }; @@ -560,13 +560,11 @@ export const graphDataPrometheusQueryRange = { weight: 2, metrics: [ { - id: 'metric_a1', - metricId: '2', + metricId: '2_metric_a', query_range: 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024', unit: 'MB', label: 'Total Consumption', - metric_id: 2, prometheus_endpoint_path: '/root/kubernetes-gke-project/environments/35/prometheus/api/v1/query?query=max%28go_memstats_alloc_bytes%7Bjob%3D%22prometheus%22%7D%29+by+%28job%29+%2F1024%2F1024', result: [ @@ -587,13 +585,12 @@ export const graphDataPrometheusQueryRangeMultiTrack = { y_label: 'Time', metrics: [ { - metricId: '1', + 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', - metric_id: 1, 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: [ @@ -669,8 +666,7 @@ export const stackedColumnMockedData = { series_name: 'group 1', prometheus_endpoint_path: '/root/autodevops-deploy-6/-/environments/24/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024', - metric_id: 'undefined_metric_of_ages_1024', - metricId: 'undefined_metric_of_ages_1024', + metricId: 'NO_DB_metric_of_ages_1024', result: [ { metric: {}, @@ -688,8 +684,7 @@ export const stackedColumnMockedData = { series_name: 'group 2', prometheus_endpoint_path: '/root/autodevops-deploy-6/-/environments/24/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024', - metric_id: 'undefined_metric_of_ages_1000', - metricId: 'undefined_metric_of_ages_1000', + metricId: 'NO_DB_metric_of_ages_1000', result: [ { metric: {}, @@ -713,8 +708,7 @@ export const barMockData = { { id: 'sla_trends_primary_services', series_name: 'group 1', - metric_id: 'undefined_sla_trends_primary_services', - metricId: 'undefined_sla_trends_primary_services', + metricId: 'NO_DB_sla_trends_primary_services', query_range: 'avg(avg_over_time(slo_observation_status{environment="gprd", stage=~"main|", type=~"api|web|git|registry|sidekiq|ci-runners"}[1d])) by (type)', unit: 'Percentile', diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index e9f9aa0ba18..9f0b4d16fc1 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -557,6 +557,86 @@ describe('Monitoring store actions', () => { ).catch(done.fail); }); + describe('without metric defined step', () => { + const expectedParams = { + start_time: '2019-08-06T12:40:02.184Z', + end_time: '2019-08-06T20:40:02.184Z', + step: 60, + }; + + it('uses calculated step', done => { + mock.onGet('http://test').reply(200, { data }); // One attempt + + testAction( + fetchPrometheusMetric, + { metric, params }, + state, + [ + { + type: types.REQUEST_METRIC_RESULT, + payload: { + metricId: metric.metricId, + }, + }, + { + type: types.RECEIVE_METRIC_RESULT_SUCCESS, + payload: { + metricId: metric.metricId, + result: data.result, + }, + }, + ], + [], + () => { + expect(mock.history.get[0].params).toEqual(expectedParams); + done(); + }, + ).catch(done.fail); + }); + }); + + describe('with metric defined step', () => { + beforeEach(() => { + metric.step = 7; + }); + + const expectedParams = { + start_time: '2019-08-06T12:40:02.184Z', + end_time: '2019-08-06T20:40:02.184Z', + step: 7, + }; + + it('uses metric step', done => { + mock.onGet('http://test').reply(200, { data }); // One attempt + + testAction( + fetchPrometheusMetric, + { metric, params }, + state, + [ + { + type: types.REQUEST_METRIC_RESULT, + payload: { + metricId: metric.metricId, + }, + }, + { + type: types.RECEIVE_METRIC_RESULT_SUCCESS, + payload: { + metricId: metric.metricId, + result: data.result, + }, + }, + ], + [], + () => { + expect(mock.history.get[0].params).toEqual(expectedParams); + done(); + }, + ).catch(done.fail); + }); + }); + it('commits result, when waiting for results', done => { // Mock multiple attempts while the cache is filling up mock.onGet('http://test').replyOnce(statusCodes.NO_CONTENT); diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index 5a14ffc03f2..bc62ada1034 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -1,3 +1,4 @@ +import _ from 'lodash'; import * as getters from '~/monitoring/stores/getters'; import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; @@ -274,4 +275,56 @@ describe('Monitoring store Getters', () => { }); }); }); + + describe('metricsSavedToDb', () => { + let metricsSavedToDb; + let state; + let mockData; + + beforeEach(() => { + mockData = _.cloneDeep(metricsDashboardPayload); + state = { + dashboard: { + panelGroups: [], + }, + }; + }); + + it('return no metrics when dashboard is not persisted', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, mockData); + metricsSavedToDb = getters.metricsSavedToDb(state); + + expect(metricsSavedToDb).toEqual([]); + }); + + it('return a metric id when one metric is persisted', () => { + const id = 99; + + const [metric] = mockData.panel_groups[0].panels[0].metrics; + + metric.metric_id = id; + + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, mockData); + metricsSavedToDb = getters.metricsSavedToDb(state); + + expect(metricsSavedToDb).toEqual([`${id}_${metric.id}`]); + }); + + it('return a metric id when two metrics are persisted', () => { + const id1 = 101; + const id2 = 102; + + const [metric1] = mockData.panel_groups[0].panels[0].metrics; + const [metric2] = mockData.panel_groups[0].panels[1].metrics; + + // database persisted 2 metrics + metric1.metric_id = id1; + metric2.metric_id = id2; + + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, mockData); + metricsSavedToDb = getters.metricsSavedToDb(state); + + expect(metricsSavedToDb).toEqual([`${id1}_${metric1.id}`, `${id2}_${metric2.id}`]); + }); + }); }); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 0310c7e9510..6f1a81782f3 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -66,13 +66,13 @@ describe('Monitoring mutations', () => { const groups = getGroups(); expect(groups[0].panels[0].metrics[0].metricId).toEqual( - 'undefined_system_metrics_kubernetes_container_memory_total', + 'NO_DB_system_metrics_kubernetes_container_memory_total', ); expect(groups[1].panels[0].metrics[0].metricId).toEqual( - 'undefined_response_metrics_nginx_ingress_throughput_status_code', + 'NO_DB_response_metrics_nginx_ingress_throughput_status_code', ); expect(groups[2].panels[0].metrics[0].metricId).toEqual( - 'undefined_response_metrics_nginx_ingress_16_throughput_status_code', + 'NO_DB_response_metrics_nginx_ingress_16_throughput_status_code', ); }); }); @@ -184,7 +184,7 @@ describe('Monitoring mutations', () => { }); describe('Individual panel/metric results', () => { - const metricId = 'undefined_response_metrics_nginx_ingress_throughput_status_code'; + const metricId = 'NO_DB_response_metrics_nginx_ingress_throughput_status_code'; const result = [ { values: [[0, 1], [1, 1], [1, 3]], diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js index 3e83ba2858e..e1c8e694122 100644 --- a/spec/frontend/monitoring/store/utils_spec.js +++ b/spec/frontend/monitoring/store/utils_spec.js @@ -307,7 +307,7 @@ describe('mapToDashboardViewModel', () => { describe('uniqMetricsId', () => { [ - { input: { id: 1 }, expected: 'undefined_1' }, + { input: { id: 1 }, expected: 'NO_DB_1' }, { input: { metric_id: 2 }, expected: '2_undefined' }, { input: { metric_id: 2, id: 21 }, expected: '2_21' }, { input: { metric_id: 22, id: 1 }, expected: '22_1' }, diff --git a/spec/uploaders/file_uploader_spec.rb b/spec/uploaders/file_uploader_spec.rb index efdbea85d4a..5fd64da6328 100644 --- a/spec/uploaders/file_uploader_spec.rb +++ b/spec/uploaders/file_uploader_spec.rb @@ -145,11 +145,39 @@ describe FileUploader do end describe '.extract_dynamic_path' do - it 'works with hashed storage' do - path = 'export/4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a/test/uploads/72a497a02fe3ee09edae2ed06d390038/dummy.txt' + context 'with a 32-byte hexadecimal secret in the path' do + let(:secret) { SecureRandom.hex } + let(:path) { "export/4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a/test/uploads/#{secret}/dummy.txt" } - expect(described_class.extract_dynamic_path(path)[:identifier]).to eq('dummy.txt') - expect(described_class.extract_dynamic_path(path)[:secret]).to eq('72a497a02fe3ee09edae2ed06d390038') + it 'extracts the secret' do + expect(described_class.extract_dynamic_path(path)[:secret]).to eq(secret) + end + + it 'extracts the identifier' do + expect(described_class.extract_dynamic_path(path)[:identifier]).to eq('dummy.txt') + end + end + + context 'with a 10-byte hexadecimal secret in the path' do + let(:secret) { SecureRandom.hex(10) } + let(:path) { "export/4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a/test/uploads/#{secret}/dummy.txt" } + + it 'extracts the secret' do + expect(described_class.extract_dynamic_path(path)[:secret]).to eq(secret) + end + + it 'extracts the identifier' do + expect(described_class.extract_dynamic_path(path)[:identifier]).to eq('dummy.txt') + end + end + + context 'with an invalid secret in the path' do + let(:secret) { 'foo' } + let(:path) { "export/4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a/test/uploads/#{secret}/dummy.txt" } + + it 'returns nil' do + expect(described_class.extract_dynamic_path(path)).to be_nil + end end end |