diff options
Diffstat (limited to 'app')
68 files changed, 720 insertions, 190 deletions
diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue new file mode 100644 index 00000000000..020a0d43096 --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue @@ -0,0 +1,121 @@ +<script> +import { GlFormInput, GlFormSelect } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { PERCENT_ROLLOUT_GROUP_ID } from '../../constants'; +import ParameterFormGroup from './parameter_form_group.vue'; + +export default { + components: { + GlFormInput, + GlFormSelect, + ParameterFormGroup, + }, + props: { + strategy: { + required: true, + type: Object, + }, + }, + i18n: { + percentageDescription: __('Enter a whole number between 0 and 100'), + percentageInvalid: __('Percent rollout must be a whole number between 0 and 100'), + percentageLabel: __('Percentage'), + stickinessDescription: __('Consistency guarantee method'), + stickinessLabel: __('Based on'), + }, + stickinessOptions: [ + { + value: 'DEFAULT', + text: __('Available ID'), + }, + { + value: 'USERID', + text: __('User ID'), + }, + { + value: 'SESSIONID', + text: __('Session ID'), + }, + { + value: 'RANDOM', + text: __('Random'), + }, + ], + computed: { + isValid() { + const percentageNum = Number(this.percentage); + return Number.isInteger(percentageNum) && percentageNum >= 0 && percentageNum <= 100; + }, + percentage() { + return this.strategy?.parameters?.rollout ?? '100'; + }, + stickiness() { + return this.strategy?.parameters?.stickiness ?? this.$options.stickinessOptions[0].value; + }, + }, + methods: { + onPercentageChange(value) { + this.$emit('change', { + parameters: { + groupId: PERCENT_ROLLOUT_GROUP_ID, + rollout: value, + stickiness: this.stickiness, + }, + }); + }, + onStickinessChange(value) { + this.$emit('change', { + parameters: { + groupId: PERCENT_ROLLOUT_GROUP_ID, + rollout: this.percentage, + stickiness: value, + }, + }); + }, + }, +}; +</script> +<template> + <div class="gl-display-flex"> + <div class="gl-mr-7" data-testid="strategy-flexible-rollout-percentage"> + <parameter-form-group + :label="$options.i18n.percentageLabel" + :description="isValid ? $options.i18n.percentageDescription : ''" + :invalid-feedback="$options.i18n.percentageInvalid" + :state="isValid" + > + <template #default="{ inputId }"> + <div class="gl-display-flex gl-align-items-center"> + <gl-form-input + :id="inputId" + :value="percentage" + :state="isValid" + class="rollout-percentage gl-text-right gl-w-9" + type="number" + min="0" + max="100" + @input="onPercentageChange" + /> + <span class="ml-1">%</span> + </div> + </template> + </parameter-form-group> + </div> + + <div class="gl-mr-7" data-testid="strategy-flexible-rollout-stickiness"> + <parameter-form-group + :label="$options.i18n.stickinessLabel" + :description="$options.i18n.stickinessDescription" + > + <template #default="{ inputId }"> + <gl-form-select + :id="inputId" + :value="stickiness" + :options="$options.stickinessOptions" + @change="onStickinessChange" + /> + </template> + </parameter-form-group> + </div> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue index b13bd86e900..ec97e8b1350 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue @@ -49,7 +49,7 @@ export default { :state="hasUserLists" :invalid-feedback="$options.translations.rolloutUserListNoListError" :label="$options.translations.rolloutUserListLabel" - :description="$options.translations.rolloutUserListDescription" + :description="hasUserLists ? $options.translations.rolloutUserListDescription : ''" > <template #default="{ inputId }"> <gl-form-select diff --git a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue index 9311589c364..d262769c891 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue @@ -15,7 +15,7 @@ export default { type: Object, }, }, - translations: { + i18n: { rolloutPercentageDescription: __('Enter a whole number between 0 and 100'), rolloutPercentageInvalid: s__( 'FeatureFlags|Percent rollout must be a whole number between 0 and 100', @@ -24,10 +24,11 @@ export default { }, computed: { isValid() { - return Number(this.percentage) >= 0 && Number(this.percentage) <= 100; + const percentageNum = Number(this.percentage); + return Number.isInteger(percentageNum) && percentageNum >= 0 && percentageNum <= 100; }, percentage() { - return this.strategy?.parameters?.percentage ?? ''; + return this.strategy?.parameters?.percentage ?? '100'; }, }, methods: { @@ -44,9 +45,9 @@ export default { </script> <template> <parameter-form-group - :label="$options.translations.rolloutPercentageLabel" - :description="$options.translations.rolloutPercentageDescription" - :invalid-feedback="$options.translations.rolloutPercentageInvalid" + :label="$options.i18n.rolloutPercentageLabel" + :description="isValid ? $options.i18n.rolloutPercentageDescription : ''" + :invalid-feedback="$options.i18n.rolloutPercentageInvalid" :state="isValid" > <template #default="{ inputId }"> diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue index c83e2c897e3..ae559a4c9e3 100644 --- a/app/assets/javascripts/feature_flags/components/strategy.vue +++ b/app/assets/javascripts/feature_flags/components/strategy.vue @@ -1,15 +1,20 @@ <script> import Vue from 'vue'; import { isNumber } from 'lodash'; -import { GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui'; +import { GlAlert, GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui'; import { s__, __ } from '~/locale'; -import { EMPTY_PARAMETERS, STRATEGY_SELECTIONS } from '../constants'; +import { + EMPTY_PARAMETERS, + STRATEGY_SELECTIONS, + ROLLOUT_STRATEGY_PERCENT_ROLLOUT, +} from '../constants'; import NewEnvironmentsDropdown from './new_environments_dropdown.vue'; import StrategyParameters from './strategy_parameters.vue'; export default { components: { + GlAlert, GlButton, GlFormGroup, GlFormSelect, @@ -51,13 +56,13 @@ export default { i18n: { allEnvironments: __('All environments'), environmentsLabel: __('Environments'), - rolloutUserListLabel: s__('FeatureFlag|List'), - rolloutUserListDescription: s__('FeatureFlag|Select a user list'), - rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'), - strategyTypeDescription: __('Select strategy activation method.'), + strategyTypeDescription: __('Select strategy activation method'), strategyTypeLabel: s__('FeatureFlag|Type'), environmentsSelectDescription: s__( - 'FeatureFlag|Select the environment scope for this feature flag.', + 'FeatureFlag|Select the environment scope for this feature flag', + ), + considerFlexibleRollout: s__( + 'FeatureFlags|Consider using the more flexible "Percent rollout" strategy instead.', ), }, @@ -85,6 +90,9 @@ export default { filteredEnvironments() { return this.environments.filter(e => !e.shouldBeDestroyed); }, + isPercentUserRollout() { + return this.formStrategy.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT; + }, }, methods: { addEnvironment(environment) { @@ -121,73 +129,84 @@ export default { }; </script> <template> - <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6"> - <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap"> - <div class="mr-5"> - <gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId"> - <p class="gl-display-inline-block ">{{ $options.i18n.strategyTypeDescription }}</p> - <gl-link :href="strategyTypeDocsPagePath" target="_blank"> - <gl-icon name="question" /> - </gl-link> - <gl-form-select - :id="strategyTypeId" - :value="formStrategy.name" - :options="$options.strategies" - @change="onStrategyTypeChange" + <div> + <gl-alert v-if="isPercentUserRollout" variant="tip" :dismissible="false"> + {{ $options.i18n.considerFlexibleRollout }} + </gl-alert> + + <div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6"> + <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap"> + <div class="mr-5"> + <gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId"> + <template #description> + {{ $options.i18n.strategyTypeDescription }} + <gl-link :href="strategyTypeDocsPagePath" target="_blank"> + <gl-icon name="question" /> + </gl-link> + </template> + <gl-form-select + :id="strategyTypeId" + :value="formStrategy.name" + :options="$options.strategies" + @change="onStrategyTypeChange" + /> + </gl-form-group> + </div> + + <div data-testid="strategy"> + <strategy-parameters + :strategy="strategy" + :user-lists="userLists" + @change="onStrategyChange" /> - </gl-form-group> - </div> + </div> - <div data-testid="strategy"> - <strategy-parameters - :strategy="strategy" - :user-lists="userLists" - @change="onStrategyChange" - /> + <div + class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto" + > + <gl-button + data-testid="delete-strategy-button" + variant="danger" + icon="remove" + @click="$emit('delete')" + /> + </div> </div> - <div - class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto" - > - <gl-button - data-testid="delete-strategy-button" - variant="danger" - icon="remove" - @click="$emit('delete')" - /> - </div> - </div> - <label class="gl-display-block" :for="environmentsDropdownId">{{ - $options.i18n.environmentsLabel - }}</label> - <p class="gl-display-inline-block">{{ $options.i18n.environmentsSelectDescription }}</p> - <gl-link :href="environmentsScopeDocsPath" target="_blank"> - <gl-icon name="question" /> - </gl-link> - <div class="gl-display-flex gl-flex-direction-column"> - <div - class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center" - > - <new-environments-dropdown - :id="environmentsDropdownId" - :endpoint="endpoint" - class="gl-mr-3" - @add="addEnvironment" - /> - <span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3"> - {{ $options.i18n.allEnvironments }} - </span> - <div v-else class="gl-display-flex gl-align-items-center"> - <gl-token - v-for="environment in filteredEnvironments" - :key="environment.id" - class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill" - @close="removeScope(environment)" - > - {{ environment.environmentScope }} - </gl-token> + <label class="gl-display-block" :for="environmentsDropdownId">{{ + $options.i18n.environmentsLabel + }}</label> + <div class="gl-display-flex gl-flex-direction-column"> + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center" + > + <new-environments-dropdown + :id="environmentsDropdownId" + :endpoint="endpoint" + class="gl-mr-3" + @add="addEnvironment" + /> + <span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3"> + {{ $options.i18n.allEnvironments }} + </span> + <div v-else class="gl-display-flex gl-align-items-center"> + <gl-token + v-for="environment in filteredEnvironments" + :key="environment.id" + class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill" + @close="removeScope(environment)" + > + {{ environment.environmentScope }} + </gl-token> + </div> </div> </div> + <span class="gl-display-inline-block gl-py-3"> + {{ $options.i18n.environmentsSelectDescription }} + </span> + <gl-link :href="environmentsScopeDocsPath" target="_blank"> + <gl-icon name="question" /> + </gl-link> </div> </div> </template> diff --git a/app/assets/javascripts/feature_flags/components/strategy_parameters.vue b/app/assets/javascripts/feature_flags/components/strategy_parameters.vue index 6953095daff..b6e06880315 100644 --- a/app/assets/javascripts/feature_flags/components/strategy_parameters.vue +++ b/app/assets/javascripts/feature_flags/components/strategy_parameters.vue @@ -1,18 +1,21 @@ <script> import { ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, ROLLOUT_STRATEGY_PERCENT_ROLLOUT, ROLLOUT_STRATEGY_USER_ID, ROLLOUT_STRATEGY_GITLAB_USER_LIST, } from '../constants'; import Default from './strategies/default.vue'; +import FlexibleRollout from './strategies/flexible_rollout.vue'; import PercentRollout from './strategies/percent_rollout.vue'; import UsersWithId from './strategies/users_with_id.vue'; import GitlabUserList from './strategies/gitlab_user_list.vue'; const STRATEGIES = Object.freeze({ [ROLLOUT_STRATEGY_ALL_USERS]: Default, + [ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT]: FlexibleRollout, [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: PercentRollout, [ROLLOUT_STRATEGY_USER_ID]: UsersWithId, [ROLLOUT_STRATEGY_GITLAB_USER_LIST]: GitlabUserList, diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js index 79bd6d8fe43..4843eca149a 100644 --- a/app/assets/javascripts/feature_flags/constants.js +++ b/app/assets/javascripts/feature_flags/constants.js @@ -3,6 +3,7 @@ import { s__ } from '~/locale'; export const ROLLOUT_STRATEGY_ALL_USERS = 'default'; export const ROLLOUT_STRATEGY_PERCENT_ROLLOUT = 'gradualRolloutUserId'; +export const ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT = 'flexibleRollout'; export const ROLLOUT_STRATEGY_USER_ID = 'userWithId'; export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList'; @@ -35,6 +36,10 @@ export const STRATEGY_SELECTIONS = [ text: s__('FeatureFlags|All users'), }, { + value: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, + text: s__('FeatureFlags|Percent rollout'), + }, + { value: ROLLOUT_STRATEGY_PERCENT_ROLLOUT, text: s__('FeatureFlags|Percent of users'), }, diff --git a/app/assets/javascripts/feature_flags/utils.js b/app/assets/javascripts/feature_flags/utils.js index ccb6ac17792..24c570657e6 100644 --- a/app/assets/javascripts/feature_flags/utils.js +++ b/app/assets/javascripts/feature_flags/utils.js @@ -2,6 +2,7 @@ import { s__, n__, sprintf } from '~/locale'; import { ALL_ENVIRONMENTS_NAME, ROLLOUT_STRATEGY_ALL_USERS, + ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT, ROLLOUT_STRATEGY_PERCENT_ROLLOUT, ROLLOUT_STRATEGY_USER_ID, ROLLOUT_STRATEGY_GITLAB_USER_LIST, @@ -12,6 +13,23 @@ const badgeTextByType = { name: s__('FeatureFlags|All Users'), parameters: null, }, + [ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT]: { + name: s__('FeatureFlags|Percent rollout'), + parameters: ({ parameters: { rollout, stickiness } }) => { + switch (stickiness) { + case 'USERID': + return sprintf(s__('FeatureFlags|%{percent} by user ID'), { percent: `${rollout}%` }); + case 'SESSIONID': + return sprintf(s__('FeatureFlags|%{percent} by session ID'), { percent: `${rollout}%` }); + case 'RANDOM': + return sprintf(s__('FeatureFlags|%{percent} randomly'), { percent: `${rollout}%` }); + default: + return sprintf(s__('FeatureFlags|%{percent} by available ID'), { + percent: `${rollout}%`, + }); + } + }, + }, [ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: { name: s__('FeatureFlags|Percent of users'), parameters: ({ parameters: { percentage } }) => `${percentage}%`, diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index dfce1cb75d3..0f145dbc170 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -1,17 +1,17 @@ <script> +import { GlIcon } from '@gitlab/ui'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import { featureAccessLevelNone } from '../constants'; export default { components: { + GlIcon, projectFeatureToggle, }, - model: { prop: 'value', event: 'change', }, - props: { name: { type: String, @@ -34,7 +34,6 @@ export default { default: false, }, }, - computed: { featureEnabled() { return this.value !== 0; @@ -51,7 +50,6 @@ export default { return this.disabledInput || !this.featureEnabled || this.displayOptions.length < 2; }, }, - methods: { toggleFeature(featureEnabled) { if (featureEnabled === false || this.options.length < 1) { @@ -96,7 +94,11 @@ export default { {{ optionName }} </option> </select> - <i aria-hidden="true" class="fa fa-chevron-down"> </i> + <gl-icon + name="chevron-down" + aria-hidden="true" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index e19afe67789..bcf82e264d1 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -1,5 +1,5 @@ <script> -import { GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; +import { GlIcon, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { s__ } from '~/locale'; @@ -22,6 +22,7 @@ export default { projectFeatureSetting, projectFeatureToggle, projectSettingRow, + GlIcon, GlSprintf, GlLink, GlFormCheckbox, @@ -325,7 +326,12 @@ export default { >{{ s__('ProjectSettings|Public') }}</option > </select> - <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + <gl-icon + name="chevron-down" + aria-hidden="true" + data-hidden="true" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + /> </div> </div> <span class="form-text text-muted">{{ visibilityLevelDescription }}</span> @@ -540,7 +546,12 @@ export default { >{{ featureAccessLevelEveryone[1] }}</option > </select> - <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + <gl-icon + name="chevron-down" + aria-hidden="true" + data-hidden="true" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + /> </div> </div> </project-setting-row> diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index 0851ee21289..62b7e02c52a 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -1,7 +1,6 @@ <script> import $ from 'jquery'; import { difference, union } from 'lodash'; -import { mapState, mapActions } from 'vuex'; import flash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -26,47 +25,49 @@ export default { 'projectIssuesPath', 'projectPath', ], - data: () => ({ - labelsSelectInProgress: false, - }), - computed: { - ...mapState(['selectedLabels']), - }, - mounted() { - this.setInitialState({ + data() { + return { + isLabelsSelectInProgress: false, selectedLabels: this.initiallySelectedLabels, - }); + }; }, methods: { - ...mapActions(['setInitialState', 'replaceSelectedLabels']), handleDropdownClose() { $(this.$el).trigger('hidden.gl.dropdown'); }, - handleUpdateSelectedLabels(labels) { + handleUpdateSelectedLabels(dropdownLabels) { const currentLabelIds = this.selectedLabels.map(label => label.id); - const userAddedLabelIds = labels.filter(label => label.set).map(label => label.id); - const userRemovedLabelIds = labels.filter(label => !label.set).map(label => label.id); + const userAddedLabelIds = dropdownLabels.filter(label => label.set).map(label => label.id); + const userRemovedLabelIds = dropdownLabels.filter(label => !label.set).map(label => label.id); - const issuableLabels = difference( - union(currentLabelIds, userAddedLabelIds), - userRemovedLabelIds, - ); + const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds); - this.labelsSelectInProgress = true; + this.updateSelectedLabels(labelIds); + }, + handleLabelRemove(labelId) { + const currentLabelIds = this.selectedLabels.map(label => label.id); + const labelIds = difference(currentLabelIds, [labelId]); + + this.updateSelectedLabels(labelIds); + }, + updateSelectedLabels(labelIds) { + this.isLabelsSelectInProgress = true; axios({ data: { [this.issuableType]: { - label_ids: issuableLabels, + label_ids: labelIds, }, }, method: 'put', url: this.labelsUpdatePath, }) - .then(({ data }) => this.replaceSelectedLabels(data.labels)) + .then(({ data }) => { + this.selectedLabels = data.labels; + }) .catch(() => flash(__('An error occurred while updating labels.'))) .finally(() => { - this.labelsSelectInProgress = false; + this.isLabelsSelectInProgress = false; }); }, }, @@ -76,6 +77,7 @@ export default { <template> <labels-select class="block labels js-labels-block" + :allow-label-remove="true" :allow-label-create="allowLabelCreate" :allow-label-edit="allowLabelEdit" :allow-multiselect="true" @@ -86,11 +88,12 @@ export default { :labels-fetch-path="labelsFetchPath" :labels-filter-base-path="projectIssuesPath" :labels-manage-path="labelsManagePath" - :labels-select-in-progress="labelsSelectInProgress" + :labels-select-in-progress="isLabelsSelectInProgress" :selected-labels="selectedLabels" :variant="$options.sidebar" data-qa-selector="labels_block" @onDropdownClose="handleDropdownClose" + @onLabelRemove="handleLabelRemove" @updateSelectedLabels="handleUpdateSelectedLabels" > {{ __('None') }} diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index a25a7b0b2fe..00b4e2de5e5 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -1,7 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import Vuex from 'vuex'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarLabels from './components/labels/sidebar_labels.vue'; @@ -17,11 +16,9 @@ import createDefaultClient from '~/lib/graphql'; import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; Vue.use(Translate); Vue.use(VueApollo); -Vue.use(Vuex); function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-options')) { return JSON.parse(sidebarOptEl.innerHTML); @@ -94,8 +91,6 @@ export function mountSidebarLabels() { return false; } - const labelsStore = new Vuex.Store(labelsSelectModule()); - return new Vue({ el, provide: { @@ -105,7 +100,6 @@ export function mountSidebarLabels() { allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels), }, - store: labelsStore, render: createElement => createElement(SidebarLabels), }); } diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue index 4cd74305450..e5e7cdf149c 100644 --- a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue +++ b/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue @@ -8,11 +8,13 @@ import { import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils'; import { __ } from '~/locale'; import { AVATAR_SIZE } from '../constants'; +import { glEmojiTag } from '~/emoji'; export default { name: 'UserAvatar', avatarSize: AVATAR_SIZE, orphanedUserLabel: __('Orphaned member'), + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, components: { GlAvatarLink, GlAvatarLabeled, @@ -38,6 +40,12 @@ export default { badges() { return generateBadges(this.member, this.isCurrentUser).filter(badge => badge.show); }, + statusEmoji() { + return this.user?.status?.emoji; + }, + }, + methods: { + glEmojiTag, }, }; </script> @@ -60,6 +68,9 @@ export default { :entity-id="user.id" > <template #meta> + <div v-if="statusEmoji" class="gl-p-1"> + <span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)"></span> + </div> <div v-for="badge in badges" :key="badge.text" class="gl-p-1"> <gl-badge size="sm" :variant="badge.variant"> {{ badge.text }} diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/vue_shared/components/members/constants.js index 8b504d06bb2..665ba1df547 100644 --- a/app/assets/javascripts/vue_shared/components/members/constants.js +++ b/app/assets/javascripts/vue_shared/components/members/constants.js @@ -38,8 +38,8 @@ export const FIELDS = [ { key: 'maxRole', label: __('Max role'), - thClass: 'col-meta', - tdClass: 'col-meta', + thClass: 'col-max-role', + tdClass: 'col-max-role', }, { key: 'expiration', diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue index d3c77c1d3ee..4580e4a9f19 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue @@ -1,6 +1,6 @@ <script> import { mapState } from 'vuex'; -import { GlTable } from '@gitlab/ui'; +import { GlTable, GlBadge } from '@gitlab/ui'; import { FIELDS } from '../constants'; import initUserPopovers from '~/user_popovers'; import MemberAvatar from './member_avatar.vue'; @@ -9,17 +9,20 @@ import CreatedAt from './created_at.vue'; import ExpiresAt from './expires_at.vue'; import MemberActionButtons from './member_action_buttons.vue'; import MembersTableCell from './members_table_cell.vue'; +import RoleDropdown from './role_dropdown.vue'; export default { name: 'MembersTable', components: { GlTable, + GlBadge, MemberAvatar, CreatedAt, ExpiresAt, MembersTableCell, MemberSource, MemberActionButtons, + RoleDropdown, }, computed: { ...mapState(['members', 'tableFields']), @@ -77,6 +80,13 @@ export default { <expires-at :date="expiresAt" /> </template> + <template #cell(maxRole)="{ item: member }"> + <members-table-cell #default="{ permissions }" :member="member"> + <role-dropdown v-if="permissions.canUpdate" :member="member" /> + <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge> + </members-table-cell> + </template> + <template #cell(actions)="{ item: member }"> <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> <member-action-buttons diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue index 1ffba579f40..5602978bb6c 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue @@ -33,7 +33,7 @@ export default { return MEMBER_TYPES.user; }, isDirectMember() { - return this.member.source?.id === this.sourceId; + return this.isGroup || this.member.source?.id === this.sourceId; }, isCurrentUser() { return this.member.user?.id === this.currentUserId; @@ -44,6 +44,9 @@ export default { canResend() { return Boolean(this.member.invite?.canResend); }, + canUpdate() { + return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate; + }, }, render() { return this.$scopedSlots.default({ @@ -53,6 +56,7 @@ export default { permissions: { canRemove: this.canRemove, canResend: this.canResend, + canUpdate: this.canUpdate, }, }); }, diff --git a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue new file mode 100644 index 00000000000..604dc942be2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue @@ -0,0 +1,49 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; + +export default { + name: 'RoleDropdown', + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + member: { + type: Object, + required: true, + }, + }, + data() { + return { + isDesktop: false, + }; + }, + mounted() { + this.isDesktop = bp.isDesktop(); + }, + methods: { + handleSelect() { + // Vuex action will be called here to make API request and update `member.accessLevel` + }, + }, +}; +</script> + +<template> + <gl-dropdown + :right="!isDesktop" + :text="member.accessLevel.stringValue" + :header-text="__('Change permissions')" + > + <gl-dropdown-item + v-for="(value, name) in member.validRoles" + :key="value" + is-check-item + :is-checked="value === member.accessLevel.integerValue" + @click="handleSelect" + > + {{ name }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue index 286067a0d0f..a6f99289df4 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue @@ -8,8 +8,20 @@ export default { components: { GlLabel, }, + props: { + disableLabels: { + type: Boolean, + required: false, + default: false, + }, + }, computed: { - ...mapState(['selectedLabels', 'allowScopedLabels', 'labelsFilterBasePath']), + ...mapState([ + 'selectedLabels', + 'allowLabelRemove', + 'allowScopedLabels', + 'labelsFilterBasePath', + ]), }, methods: { labelFilterUrl(label) { @@ -42,7 +54,10 @@ export default { :background-color="label.color" :target="labelFilterUrl(label)" :scoped="scopedLabel(label)" + :show-close-button="allowLabelRemove" + :disabled="disableLabels" tooltip-placement="top" + @close="$emit('onLabelRemove', label.id)" /> </template> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 34f5517ef99..c651013c5f5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -28,6 +28,11 @@ export default { DropdownValueCollapsed, }, props: { + allowLabelRemove: { + type: Boolean, + required: false, + default: false, + }, allowLabelEdit: { type: Boolean, required: true, @@ -130,6 +135,7 @@ export default { mounted() { this.setInitialState({ variant: this.variant, + allowLabelRemove: this.allowLabelRemove, allowLabelEdit: this.allowLabelEdit, allowLabelCreate: this.allowLabelCreate, allowMultiselect: this.allowMultiselect, @@ -252,7 +258,10 @@ export default { :allow-label-edit="allowLabelEdit" :labels-select-in-progress="labelsSelectInProgress" /> - <dropdown-value> + <dropdown-value + :disable-labels="labelsSelectInProgress" + @onLabelRemove="$emit('onLabelRemove', $event)" + > <slot></slot> </dropdown-value> <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js index 2d236566b3d..e624bd1eaee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js @@ -54,8 +54,5 @@ export const createLabel = ({ state, dispatch }, label) => { }); }; -export const replaceSelectedLabels = ({ commit }, selectedLabels) => - commit(types.REPLACE_SELECTED_LABELS, selectedLabels); - export const updateSelectedLabels = ({ commit }, labels) => commit(types.UPDATE_SELECTED_LABELS, { labels }); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js index af92665d4eb..2e044dc3b3c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js @@ -15,7 +15,6 @@ export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE'; export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY'; export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS'; -export const REPLACE_SELECTED_LABELS = 'REPLACE_SELECTED_LABELS'; export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS'; export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 7edd290a819..54f8c78b4e1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -57,10 +57,6 @@ export default { state.labelCreateInProgress = false; }, - [types.REPLACE_SELECTED_LABELS](state, selectedLabels = []) { - state.selectedLabels = selectedLabels; - }, - [types.UPDATE_SELECTED_LABELS](state, { labels }) { // Find the label to update from all the labels // and change `set` prop value to represent their current state. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js index 3f3358d4805..d66cfed4163 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js @@ -15,6 +15,7 @@ export default () => ({ // UI Flags variant: '', + allowLabelRemove: false, allowLabelCreate: false, allowLabelEdit: false, allowScopedLabels: false, diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 11d5104f64d..922f95ff5df 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -216,6 +216,10 @@ width: px-to-rem(150px); } + .col-max-role { + width: px-to-rem(175px); + } + .col-expiration { width: px-to-rem(200px); } diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index ea0a0b62735..9afad86185d 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -171,7 +171,7 @@ class Admin::UsersController < Admin::ApplicationController # restore username to keep form action url. user.username = params[:id] format.html { render "edit" } - format.json { render json: [result[:message]], status: result[:status] } + format.json { render json: [result[:message]], status: :internal_server_error } end end end diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb index f4846b1aa81..ca3f36cafe1 100644 --- a/app/controllers/projects/merge_requests/drafts_controller.rb +++ b/app/controllers/projects/merge_requests/drafts_controller.rb @@ -45,7 +45,7 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli if result[:status] == :success head :ok else - render json: { message: result[:message] }, status: result[:status] + render json: { message: result[:message] }, status: :internal_server_error end end diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb index d358e3fcc0d..4086ca46a60 100644 --- a/app/graphql/types/ci/detailed_status_type.rb +++ b/app/graphql/types/ci/detailed_status_type.rb @@ -7,22 +7,22 @@ module Types graphql_name 'DetailedStatus' field :group, GraphQL::STRING_TYPE, null: false, - description: 'Group of the pipeline status' + description: 'Group of the status' field :icon, GraphQL::STRING_TYPE, null: false, - description: 'Icon of the pipeline status' + description: 'Icon of the status' field :favicon, GraphQL::STRING_TYPE, null: false, - description: 'Favicon of the pipeline status' - field :details_path, GraphQL::STRING_TYPE, null: false, - description: 'Path of the details for the pipeline status' + description: 'Favicon of the status' + field :details_path, GraphQL::STRING_TYPE, null: true, + description: 'Path of the details for the status' field :has_details, GraphQL::BOOLEAN_TYPE, null: false, - description: 'Indicates if the pipeline status has further details', + description: 'Indicates if the status has further details', method: :has_details? field :label, GraphQL::STRING_TYPE, null: false, - description: 'Label of the pipeline status' + description: 'Label of the status' field :text, GraphQL::STRING_TYPE, null: false, - description: 'Text of the pipeline status' + description: 'Text of the status' field :tooltip, GraphQL::STRING_TYPE, null: false, - description: 'Tooltip associated with the pipeline status', + description: 'Tooltip associated with the status', method: :status_tooltip field :action, Types::Ci::StatusActionType, null: true, description: 'Action information for the status. This includes method, button title, icon, path, and title', diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb index 04c0eb93068..d930ae311b7 100644 --- a/app/graphql/types/ci/group_type.rb +++ b/app/graphql/types/ci/group_type.rb @@ -12,6 +12,9 @@ module Types description: 'Size of the group' field :jobs, Ci::JobType.connection_type, null: true, description: 'Jobs in group' + field :detailed_status, Types::Ci::DetailedStatusType, null: true, + description: 'Detailed status of the group', + resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } end end end diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 4c18f3ffd52..bed0e74a920 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -10,6 +10,9 @@ module Types description: 'Name of the job' field :needs, JobType.connection_type, null: true, description: 'Builds that must complete before the jobs run' + field :detailed_status, Types::Ci::DetailedStatusType, null: true, + description: 'Detailed status of the job', + resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } end end end diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb index 278c4d4d748..fc2c72d0d06 100644 --- a/app/graphql/types/ci/stage_type.rb +++ b/app/graphql/types/ci/stage_type.rb @@ -10,6 +10,9 @@ module Types description: 'Name of the stage' field :groups, Ci::GroupType.connection_type, null: true, description: 'Group of jobs for the stage' + field :detailed_status, Types::Ci::DetailedStatusType, null: true, + description: 'Detailed status of the stage', + resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } end end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 060c155401f..aef08c433ab 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -471,7 +471,8 @@ module ProjectsHelper labels: :read_label, issues: :read_issue, project_members: :read_project_member, - wiki: :read_wiki + wiki: :read_wiki, + feature_flags: :read_feature_flag } end @@ -482,7 +483,8 @@ module ProjectsHelper :read_environment, :read_issue, :read_sentry_issue, - :read_cluster + :read_cluster, + :read_feature_flag ].any? do |ability| can?(current_user, ability, project) end @@ -561,7 +563,11 @@ module ProjectsHelper end def sidebar_operations_link_path(project = @project) - metrics_project_environments_path(project) if can?(current_user, :read_environment, project) + if can?(current_user, :read_environment, project) + metrics_project_environments_path(project) + else + project_feature_flags_path(project) + end end def project_last_activity(project) @@ -754,6 +760,7 @@ module ProjectsHelper logs product_analytics metrics_dashboard + feature_flags tracings ] end diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb new file mode 100644 index 00000000000..e74946eda16 --- /dev/null +++ b/app/models/ci/deleted_object.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Ci + class DeletedObject < ApplicationRecord + extend Gitlab::Ci::Model + + mount_uploader :file, DeletedObjectUploader + + scope :ready_for_destruction, ->(limit) do + where('pick_up_at < ?', Time.current).limit(limit) + end + + scope :lock_for_destruction, ->(limit) do + ready_for_destruction(limit) + .select(:id) + .order(:pick_up_at) + .lock('FOR UPDATE SKIP LOCKED') + end + + def self.bulk_import(artifacts) + attributes = artifacts.each.with_object([]) do |artifact, accumulator| + record = artifact.to_deleted_object_attrs + accumulator << record if record[:store_dir] && record[:file] + end + + self.insert_all(attributes) if attributes.any? + end + + def delete_file_from_storage + file.remove! + true + rescue => exception + Gitlab::ErrorTracking.track_exception(exception) + false + end + end +end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 42c0185c366..02e17afdab0 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -290,6 +290,15 @@ module Ci max_size&.megabytes.to_i end + def to_deleted_object_attrs + { + file_store: file_store, + store_dir: file.store_dir.to_s, + file: file_identifier, + pick_up_at: expire_at || Time.current + } + end + private def set_size diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index c9e05b45dbd..b29451315e8 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -841,6 +841,25 @@ module Ci end end + def build_with_artifacts_in_self_and_descendants(name) + builds_in_self_and_descendants + .ordered_by_pipeline # find job in hierarchical order + .with_downloadable_artifacts + .find_by_name(name) + end + + def builds_in_self_and_descendants + Ci::Build.latest.where(pipeline: self_and_descendants) + end + + # Without using `unscoped`, caller scope is also included into the query. + # Using `unscoped` here will be redundant after Rails 6.1 + def self_and_descendants + ::Gitlab::Ci::PipelineObjectHierarchy + .new(self.class.unscoped.where(id: id), options: { same_project: true }) + .base_and_descendants + end + def bridge_triggered? source_bridge.present? end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index b0169d6290a..4498e08d754 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -48,6 +48,7 @@ class CommitStatus < ApplicationRecord scope :ordered_by_stage, -> { order(stage_idx: :asc) } scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) } scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } + scope :ordered_by_pipeline, -> { order(pipeline_id: :asc) } scope :before_stage, -> (index) { where('stage_idx < ?', index) } scope :for_stage, -> (index) { where(stage_idx: index) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } diff --git a/app/models/group.rb b/app/models/group.rb index 1dec831606b..5a18441e0ad 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -76,6 +76,7 @@ class Group < Namespace validate :visibility_level_allowed_by_projects validate :visibility_level_allowed_by_sub_groups validate :visibility_level_allowed_by_parent + validate :two_factor_authentication_allowed validates :variables, variable_duplicates: true validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } @@ -589,6 +590,16 @@ class Group < Namespace errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.") end + def two_factor_authentication_allowed + return unless has_parent? + return unless require_two_factor_authentication + + ancestor_settings = ancestors.find_by(parent_id: nil).namespace_settings + return if ancestor_settings.allow_mfa_for_subgroups + + errors.add(:require_two_factor_authentication, _('is forbidden by a top-level group')) + end + def members_from_self_and_ancestor_group_shares group_group_link_table = GroupGroupLink.arel_table group_member_table = GroupMember.arel_table diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 40c46fa6e3d..6f31208f28b 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -4,6 +4,7 @@ class NamespaceSetting < ApplicationRecord belongs_to :namespace, inverse_of: :namespace_settings validate :default_branch_name_content + validate :allow_mfa_for_group NAMESPACE_SETTINGS_PARAMS = [:default_branch_name].freeze @@ -16,6 +17,12 @@ class NamespaceSetting < ApplicationRecord errors.add(:default_branch_name, "can not be an empty string") end end + + def allow_mfa_for_group + if namespace&.subgroup? && allow_mfa_for_subgroups == false + errors.add(:allow_mfa_for_subgroups, _('is not allowed since the group is not top-level group.')) + end + end end NamespaceSetting.prepend_if_ee('EE::NamespaceSetting') diff --git a/app/models/project.rb b/app/models/project.rb index 9fa93d9b4e4..d7f5254a6e3 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -951,7 +951,7 @@ class Project < ApplicationRecord latest_pipeline = ci_pipelines.latest_successful_for_ref(ref) return unless latest_pipeline - latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name) + latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name) end def latest_successful_build_for_sha(job_name, sha) @@ -960,7 +960,7 @@ class Project < ApplicationRecord latest_pipeline = ci_pipelines.latest_successful_for_sha(sha) return unless latest_pipeline - latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name) + latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name) end def latest_successful_build_for_ref!(job_name, ref = default_branch) diff --git a/app/models/project_services/confluence_service.rb b/app/models/project_services/confluence_service.rb index dd44a0d1d56..6db446fc04c 100644 --- a/app/models/project_services/confluence_service.rb +++ b/app/models/project_services/confluence_service.rb @@ -27,7 +27,7 @@ class ConfluenceService < Service end def description - s_('ConfluenceService|Connect a Confluence Cloud Workspace to your GitLab project') + s_('ConfluenceService|Connect a Confluence Cloud Workspace to GitLab') end def detailed_description diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb index 35dbedd1341..21f0a2b2463 100644 --- a/app/models/project_services/packagist_service.rb +++ b/app/models/project_services/packagist_service.rb @@ -16,7 +16,7 @@ class PackagistService < Service end def description - 'Update your project on Packagist, the main Composer repository' + s_('Integrations|Update your projects on Packagist, the main Composer repository') end def self.to_param diff --git a/app/services/ci/delete_objects_service.rb b/app/services/ci/delete_objects_service.rb new file mode 100644 index 00000000000..bac99abadc9 --- /dev/null +++ b/app/services/ci/delete_objects_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Ci + class DeleteObjectsService + TransactionInProgressError = Class.new(StandardError) + TRANSACTION_MESSAGE = "can't perform network calls inside a database transaction" + BATCH_SIZE = 100 + RETRY_IN = 10.minutes + + def execute + objects = load_next_batch + destroy_everything(objects) + end + + def remaining_batches_count(max_batch_count:) + Ci::DeletedObject + .ready_for_destruction(max_batch_count * BATCH_SIZE) + .size + .fdiv(BATCH_SIZE) + .ceil + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def load_next_batch + # `find_by_sql` performs a write in this case and we need to wrap it in + # a transaction to stick to the primary database. + Ci::DeletedObject.transaction do + Ci::DeletedObject.find_by_sql([ + next_batch_sql, new_pick_up_at: RETRY_IN.from_now + ]) + end + end + # rubocop: enable CodeReuse/ActiveRecord + + def next_batch_sql + <<~SQL.squish + UPDATE "ci_deleted_objects" + SET "pick_up_at" = :new_pick_up_at + WHERE "ci_deleted_objects"."id" IN (#{locked_object_ids_sql}) + RETURNING * + SQL + end + + def locked_object_ids_sql + Ci::DeletedObject.lock_for_destruction(BATCH_SIZE).to_sql + end + + def destroy_everything(objects) + raise TransactionInProgressError, TRANSACTION_MESSAGE if transaction_open? + return unless objects.any? + + deleted = objects.select(&:delete_file_from_storage) + Ci::DeletedObject.id_in(deleted.map(&:id)).delete_all + end + + def transaction_open? + Ci::DeletedObject.connection.transaction_open? + end + end +end diff --git a/app/uploaders/deleted_object_uploader.rb b/app/uploaders/deleted_object_uploader.rb new file mode 100644 index 00000000000..fc0f62b920c --- /dev/null +++ b/app/uploaders/deleted_object_uploader.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class DeletedObjectUploader < GitlabUploader + include ObjectStorage::Concern + + storage_options Gitlab.config.artifacts + + def store_dir + model.store_dir + end +end diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml index 4f55fb196d3..ae0da214fb7 100644 --- a/app/views/admin/abuse_reports/_abuse_report.html.haml +++ b/app/views/admin/abuse_reports/_abuse_report.html.haml @@ -25,8 +25,8 @@ = link_to _('Remove user & report'), admin_abuse_report_path(abuse_report, remove_user: true), data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name } }, remote: true, method: :delete, class: "gl-button btn btn-sm btn-block btn-danger js-remove-tr" - if user && !user.blocked? - = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "btn btn-sm btn-block" + = link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-sm btn-block" - else .btn.btn-sm.disabled.btn-block = _('Already blocked') - = link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr" + = link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "gl-button btn btn-sm btn-block btn-close js-remove-tr" diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 0d01f1c57e0..0c3a4e73e30 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -40,5 +40,5 @@ = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes .form-actions - = f.submit 'Submit', class: "btn btn-success wide" - = link_to "Cancel", admin_applications_path, class: "btn btn-cancel" + = f.submit 'Submit', class: "gl-button btn btn-success wide" + = link_to "Cancel", admin_applications_path, class: "gl-button btn btn-cancel" diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml index 0119cabf1ad..c1c1c2a4cfe 100644 --- a/app/views/admin/applications/index.html.haml +++ b/app/views/admin/applications/index.html.haml @@ -4,7 +4,7 @@ %p.light System OAuth applications don't belong to any user and can only be managed by admins %hr -%p= link_to 'New application', new_admin_application_path, class: 'btn btn-success' +%p= link_to 'New application', new_admin_application_path, class: 'gl-button btn btn-success' %table.table %thead %tr @@ -23,6 +23,6 @@ %td= @application_counts[application.id].to_i %td= application.trusted? ? 'Y': 'N' %td= application.confidential? ? 'Y': 'N' - %td= link_to 'Edit', edit_admin_application_path(application), class: 'btn btn-link' + %td= link_to 'Edit', edit_admin_application_path(application), class: 'gl-button btn btn-link' %td= render 'delete_form', application: application = paginate @applications, theme: 'gitlab' diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index 5259dd56df5..f029da6b3af 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -13,7 +13,7 @@ .input-group %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true } .input-group-append - = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "btn btn btn-default") + = clipboard_button(target: '#application_id', title: _("Copy ID"), class: "gl-button btn btn-default") %tr %td = _('Secret') @@ -22,7 +22,7 @@ .input-group %input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true } .input-group-append - = clipboard_button(target: '#secret', title: _("Copy secret"), class: "btn btn btn-default") + = clipboard_button(target: '#secret', title: _("Copy secret"), class: "gl-button btn btn-default") %tr %td = _('Callback URL') @@ -45,5 +45,5 @@ = render "shared/tokens/scopes_list", token: @application .form-actions - = link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide float-left' + = link_to 'Edit', edit_admin_application_path(@application), class: 'gl-button btn btn-primary wide float-left' = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3' diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 1feb2ad16ad..6174da14ac0 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -29,10 +29,10 @@ .gl-alert-body = render 'shared/group_tips' .form-actions - = f.submit _('Create group'), class: "btn btn-success" - = link_to _('Cancel'), admin_groups_path, class: "btn btn-cancel" + = f.submit _('Create group'), class: "gl-button btn btn-success" + = link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-cancel" - else .form-actions - = f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_changes_button' } - = link_to _('Cancel'), admin_group_path(@group), class: "btn btn-cancel" + = f.submit _('Save changes'), class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' } + = link_to _('Cancel'), admin_group_path(@group), class: "gl-button btn btn-cancel" diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index b8fd110461d..bc4d4e489ce 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -10,7 +10,7 @@ = search_field_tag :name, project_name, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name', data: { qa_selector: 'group_search_field' } = sprite_icon('search', css_class: 'search-icon') = render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash - = link_to new_admin_group_path, class: "btn btn-success" do + = link_to new_admin_group_path, class: "gl-button btn btn-success" do = _('New group') %ul.content-list = render @groups diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index dc43b45195e..424251f543e 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -115,7 +115,7 @@ .gl-mt-3 = select_tag :access_level, options_for_select(@group.access_level_roles), class: "project-access-select select2" %hr - = button_tag _('Add users to group'), class: "btn btn-success" + = button_tag _('Add users to group'), class: "gl-button btn btn-success" = render 'shared/members/requests', membership_source: @group, requesters: @requesters, force_mobile_view: true .card diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index 65d3c78ec11..76e4fa971a3 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -9,7 +9,7 @@ %code#health-check-token= Gitlab::CurrentSettings.health_check_access_token .gl-mt-3 = button_to _("Reset health check access token"), reset_health_check_token_admin_application_settings_path, - method: :put, class: 'btn btn-default', + method: :put, class: 'gl-button btn btn-default', data: { confirm: _('Are you sure you want to reset the health check token?') } %p.light #{ _('Health information can be retrieved from the following endpoints. More information is available') } diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml index 40a7014e143..5c62cff27c7 100644 --- a/app/views/admin/identities/_form.html.haml +++ b/app/views/admin/identities/_form.html.haml @@ -14,5 +14,5 @@ = f.text_field :extern_uid, class: 'form-control', required: true .form-actions - = f.submit _('Save changes'), class: "btn btn-success" + = f.submit _('Save changes'), class: "gl-button btn btn-success" diff --git a/app/views/admin/identities/_identity.html.haml b/app/views/admin/identities/_identity.html.haml index 5ed59809db5..d8facbb780a 100644 --- a/app/views/admin/identities/_identity.html.haml +++ b/app/views/admin/identities/_identity.html.haml @@ -4,9 +4,9 @@ %td = identity.extern_uid %td - = link_to edit_admin_user_identity_path(@user, identity), class: 'btn btn-sm btn-grouped' do + = link_to edit_admin_user_identity_path(@user, identity), class: 'gl-button btn btn-sm btn-grouped' do = _("Edit") = link_to [:admin, @user, identity], method: :delete, - class: 'btn btn-sm btn-danger', + class: 'gl-button btn btn-sm btn-danger', data: { confirm: _("Are you sure you want to remove this identity?") } do = _('Delete') diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml index 9543bbcf977..a6d562dad31 100644 --- a/app/views/admin/identities/index.html.haml +++ b/app/views/admin/identities/index.html.haml @@ -3,7 +3,7 @@ - page_title _("Identities"), @user.name, _("Users") = render 'admin/users/head' -= link_to _('New identity'), new_admin_user_identity_path, class: 'float-right btn btn-success' += link_to _('New identity'), new_admin_user_identity_path, class: 'float-right gl-button btn btn-success' - if @identities.present? .table-holder %table.table diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index 08e668e8623..bcf09dfc0d2 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -30,8 +30,8 @@ = dropdown_content = dropdown_loading = render 'shared/projects/dropdown' - = link_to new_project_path, class: 'btn btn-success' do + = link_to new_project_path, class: 'gl-button btn btn-success' do New Project - = button_tag "Search", class: "btn btn-primary btn-search hide" + = button_tag "Search", class: "gl-button btn btn-primary btn-search hide" = render 'projects' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 01a0b4d295d..417fd1d60eb 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -149,7 +149,7 @@ .form-group.row .offset-sm-3.col-sm-9 - = f.submit _('Transfer'), class: 'btn btn-primary' + = f.submit _('Transfer'), class: 'gl-button btn btn-primary' .card.repository-check .card-header @@ -169,7 +169,7 @@ = link_to sprite_icon('question-o'), help_page_path('administration/repository_checks') .form-group - = f.submit _('Trigger repository check'), class: 'btn btn-primary' + = f.submit _('Trigger repository check'), class: 'gl-button btn btn-primary' .col-md-6 - if @group diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index a2b736c332c..cc8ac6b0642 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -65,15 +65,15 @@ .table-section.table-button-footer.section-10 .btn-group.table-action-buttons .btn-group - = link_to admin_runner_path(runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do + = link_to admin_runner_path(runner), class: 'gl-button btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do = sprite_icon('pencil') .btn-group - if runner.active? - = link_to [:pause, :admin, runner], method: :get, class: 'btn btn-default btn-svg has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do + = link_to [:pause, :admin, runner], method: :get, class: 'gl-button btn btn-default btn-svg has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do = sprite_icon('pause') - else - = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default btn-svg has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do + = link_to [:resume, :admin, runner], method: :get, class: 'gl-button btn btn-default btn-svg has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do = sprite_icon('play') .btn-group - = link_to [:admin, runner], method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do + = link_to [:admin, runner], method: :delete, class: 'gl-button btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do = sprite_icon('close') diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index cc218aefdb7..3d3b8c28a17 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -48,7 +48,7 @@ .filtered-search-box = dropdown_tag(_('Recent searches'), options: { wrapper_class: 'filtered-search-history-dropdown-wrapper', - toggle_class: 'btn filtered-search-history-dropdown-toggle-button', + toggle_class: 'gl-button btn filtered-search-history-dropdown-toggle-button', dropdown_class: 'filtered-search-history-dropdown', content_class: 'filtered-search-history-dropdown-content' }) do .js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } } @@ -60,7 +60,7 @@ #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } } - = button_tag class: %w[btn btn-link] do + = button_tag class: %w[gl-button btn btn-link] do -# Encapsulate static class name `{{icon}}` inside #{} to bypass -# haml lint's ClassAttributeWithStaticValue %svg @@ -78,21 +78,21 @@ %ul{ data: { dropdown: true } } - Ci::Runner::AVAILABLE_STATUSES.each do |status| %li.filter-dropdown-item{ data: { value: status } } - = button_tag class: %w[btn btn-link] do + = button_tag class: %w[gl-button btn btn-link] do = status.titleize #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - Ci::Runner::AVAILABLE_TYPES.each do |runner_type| %li.filter-dropdown-item{ data: { value: runner_type } } - = button_tag class: %w[btn btn-link] do + = button_tag class: %w[gl-button btn btn-link] do = runner_type.titleize #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } - Ci::Runner::AVAILABLE_TYPES.each do |runner_type| %li.filter-dropdown-item{ data: { value: runner_type } } - = button_tag class: %w[btn btn-link] do + = button_tag class: %w[gl-button btn btn-link] do = runner_type.titleize #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index cecf3f137ed..2c4befb1be2 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -49,7 +49,7 @@ = project.full_name %td .float-right - = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn btn-danger btn-sm' + = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'gl-button btn btn-danger btn-sm' %table.table.unassigned-projects %thead @@ -73,7 +73,7 @@ .float-right = form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f| = f.hidden_field :runner_id, value: @runner.id - = f.submit 'Enable', class: 'btn btn-sm' + = f.submit 'Enable', class: 'gl-button btn btn-sm' = paginate_without_count @projects .col-md-6 diff --git a/app/views/admin/serverless/domains/_form.html.haml b/app/views/admin/serverless/domains/_form.html.haml index fab1795e136..8f0dd0cab8e 100644 --- a/app/views/admin/serverless/domains/_form.html.haml +++ b/app/views/admin/serverless/domains/_form.html.haml @@ -16,7 +16,7 @@ - text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success'] .badge{ class: status } = text - = link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "btn has-tooltip", title: _("Retry verification") + = link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "gl-button btn has-tooltip", title: _("Retry verification") .col-sm-6 = f.label :serverless_domain_dns, _('DNS'), class: 'label-bold' @@ -65,7 +65,7 @@ %span.form-text.text-muted = _("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? + = f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "gl-button btn btn-success js-serverless-domain-submit", disabled: @domain.persisted? - if @domain.persisted? %button.gl-button.btn.btn-danger{ type: 'button', data: { toggle: 'modal', target: "#modal-delete-domain" } } = _('Delete domain') @@ -88,7 +88,7 @@ = _("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' } + %a{ href: '#', data: { dismiss: 'modal' }, class: 'gl-button btn btn-default' } = _('Cancel') = link_to _('Delete domain'), diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index 5be1c90d6aa..47ef4f26889 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -4,4 +4,4 @@ = password_field_tag 'user[password]', nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } .submit-container.move-submit-down - = submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'enter_admin_mode_button' } + = submit_tag _('Enter Admin Mode'), class: 'gl-button btn btn-success', data: { qa_selector: 'enter_admin_mode_button' } diff --git a/app/views/admin/sessions/_two_factor_otp.html.haml b/app/views/admin/sessions/_two_factor_otp.html.haml index 8d5588de06e..3fe6e20a367 100644 --- a/app/views/admin/sessions/_two_factor_otp.html.haml +++ b/app/views/admin/sessions/_two_factor_otp.html.haml @@ -6,4 +6,4 @@ = _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.") .submit-container.move-submit-down - = submit_tag 'Verify code', class: 'btn btn-success' + = submit_tag 'Verify code', class: 'gl-button btn btn-success' diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index a381efcb0f2..2e7114ddab4 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -30,10 +30,10 @@ .btn.btn-sm.disabled Submitted as ham - else - = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'btn btn-sm btn-warning' + = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'gl-button btn btn-sm btn-warning' - if user && !user.blocked? - = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm" + = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "gl-button btn btn-sm" - else .btn.btn-sm.disabled Already blocked - = link_to 'Remove log', [:admin, spam_log], remote: true, method: :delete, class: "btn btn-sm btn-close js-remove-tr" + = link_to 'Remove log', [:admin, spam_log], remote: true, method: :delete, class: "gl-button btn btn-sm btn-close js-remove-tr" diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 9e31c8d2852..61c31d2d864 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -88,7 +88,7 @@ .form-actions - if @user.new_record? = f.submit 'Create user', class: "btn gl-button btn-success" - = link_to 'Cancel', admin_users_path, class: "btn btn-cancel" + = link_to 'Cancel', admin_users_path, class: "gl-button btn btn-cancel" - else = f.submit 'Save changes', class: "btn gl-button btn-success" = link_to 'Cancel', admin_user_path(@user), class: "btn gl-button btn-cancel" diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml index 32dd9a7c275..349b3328c12 100644 --- a/app/views/jira_connect/subscriptions/index.html.haml +++ b/app/views/jira_connect/subscriptions/index.html.haml @@ -24,5 +24,14 @@ %td= subscription.created_at %td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'remove-subscription' +%p + %strong Browser limitations: + Adding a namespace currently works only in browsers that allow cross site cookies. Please make sure to use + %a{ href: 'https://www.mozilla.org/en-US/firefox/', target: '_blank', rel: 'noopener noreferrer' } Firefox + or + %a{ href: 'https://www.google.com/chrome/index.html', target: '_blank', rel: 'noopener noreferrer' } Google Chrome + or enable cross-site cookies in your browser when adding a namespace. + %a{ href: 'https://gitlab.com/gitlab-org/gitlab/-/issues/263509', target: '_blank', rel: 'noopener noreferrer' } Learn more + = page_specific_javascript_tag('jira_connect.js') - add_page_specific_style 'page_bundles/jira_connect' diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index a068643cdfe..d3d71f91176 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -297,7 +297,11 @@ %span = _('Environments') - = render_if_exists 'layouts/nav/sidebar/project_feature_flags_link' + - if project_nav_tab? :feature_flags + = nav_link(controller: :feature_flags) do + = link_to project_feature_flags_path(@project), title: _('Feature Flags'), class: 'shortcuts-feature-flags' do + %span + = _('Feature Flags') - if project_nav_tab?(:product_analytics) = nav_link(controller: :product_analytics) do diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 5b7a0b99598..9baa340376b 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -12,7 +12,7 @@ .timeline-entry-inner .flash-container.timeline-content - .timeline-icon.d-none.d-sm-none.d-md-block + .timeline-icon.d-none.d-md-block %a.author-link{ href: user_path(current_user) } = image_tag avatar_icon_for_user(current_user), alt: current_user.to_reference, class: 'avatar s40' .timeline-content.timeline-content-form diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index efb766e1f0b..1e2cded0618 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -147,6 +147,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: cronjob:ci_schedule_delete_objects_cron + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: cronjob:container_expiration_policy :feature_category: :container_registry :has_external_dependencies: @@ -1305,6 +1313,14 @@ :idempotent: :tags: - :requires_disk_io +- :name: ci_delete_objects + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: create_commit_signature :feature_category: :source_code_management :has_external_dependencies: diff --git a/app/workers/ci/delete_objects_worker.rb b/app/workers/ci/delete_objects_worker.rb new file mode 100644 index 00000000000..e34be33b438 --- /dev/null +++ b/app/workers/ci/delete_objects_worker.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Ci + class DeleteObjectsWorker + include ApplicationWorker + include LimitedCapacity::Worker + + feature_category :continuous_integration + idempotent! + + def perform_work(*args) + service.execute + end + + def remaining_work_count(*args) + @remaining_work_count ||= service + .remaining_batches_count(max_batch_count: remaining_capacity) + end + + def max_running_jobs + if ::Feature.enabled?(:ci_delete_objects_low_concurrency) + 2 + elsif ::Feature.enabled?(:ci_delete_objects_medium_concurrency) + 20 + elsif ::Feature.enabled?(:ci_delete_objects_high_concurrency) + 50 + else + 0 + end + end + + private + + def service + @service ||= DeleteObjectsService.new + end + end +end diff --git a/app/workers/ci/schedule_delete_objects_cron_worker.rb b/app/workers/ci/schedule_delete_objects_cron_worker.rb new file mode 100644 index 00000000000..fa0b15deb56 --- /dev/null +++ b/app/workers/ci/schedule_delete_objects_cron_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ci + class ScheduleDeleteObjectsCronWorker + include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + feature_category :continuous_integration + idempotent! + + def perform(*args) + Ci::DeleteObjectsWorker.perform_with_capacity(*args) + end + end +end |