diff options
Diffstat (limited to 'app')
302 files changed, 5606 insertions, 1429 deletions
diff --git a/app/assets/images/cluster_app_logos/fluentd.png b/app/assets/images/cluster_app_logos/fluentd.png Binary files differnew file mode 100644 index 00000000000..6d42578f2ce --- /dev/null +++ b/app/assets/images/cluster_app_logos/fluentd.png diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue new file mode 100644 index 00000000000..f7910e5d3fa --- /dev/null +++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue @@ -0,0 +1,62 @@ +<script> +import { GlEmptyState, GlButton, GlLoadingIcon } from '@gitlab/ui'; + +export default { + components: { + GlEmptyState, + GlButton, + GlLoadingIcon, + }, + props: { + indexPath: { + type: String, + required: true, + }, + enableAlertManagementPath: { + type: String, + required: true, + }, + emptyAlertSvgPath: { + type: String, + required: true, + }, + }, + data() { + return { + alerts: [], + loading: false, + }; + }, +}; +</script> + +<template> + <div> + <div v-if="alerts.length > 0" class="alert-management-list"> + <div v-if="loading" class="py-3"> + <gl-loading-icon size="md" /> + </div> + </div> + <template v-else> + <gl-empty-state :title="__('Surface alerts in GitLab')" :svg-path="emptyAlertSvgPath"> + <template #description> + <div class="d-block"> + <span>{{ + __( + 'Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.', + ) + }}</span> + <a href="/help/user/project/operations/alert_management.html"> + {{ __('More information') }} + </a> + </div> + <div class="d-block center pt-4"> + <gl-button category="primary" variant="success" :href="enableAlertManagementPath">{{ + __('Authorize external service') + }}</gl-button> + </div> + </template> + </gl-empty-state> + </template> + </div> +</template> diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js new file mode 100644 index 00000000000..9f0efbc999a --- /dev/null +++ b/app/assets/javascripts/alert_management/list.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import AlertManagementList from './components/alert_management_list.vue'; + +export default () => { + const selector = '#js-alert_management'; + + const domEl = document.querySelector(selector); + const { indexPath, enableAlertManagementPath, emptyAlertSvgPath } = domEl.dataset; + + return new Vue({ + el: selector, + components: { + AlertManagementList, + }, + render(createElement) { + return createElement('alert-management-list', { + props: { + indexPath, + enableAlertManagementPath, + emptyAlertSvgPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/alert_management/services/index.js b/app/assets/javascripts/alert_management/services/index.js new file mode 100644 index 00000000000..787603d3e7a --- /dev/null +++ b/app/assets/javascripts/alert_management/services/index.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + getAlertManagementList({ endpoint }) { + return axios.get(endpoint); + }, +}; diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 6301f6a3910..904bf117dc0 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -46,6 +46,7 @@ const Api = { mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', adminStatisticsPath: '/api/:version/application/statistics', pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', + pipelinesPath: '/api/:version/projects/:id/pipelines/', environmentsPath: '/api/:version/projects/:id/environments', rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw', @@ -502,6 +503,15 @@ const Api = { return axios.get(url); }, + // Return all pipelines for a project or filter by query params + pipelines(id, options = {}) { + const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url, { + params: options, + }); + }, + environments(id) { const url = Api.buildUrl(this.environmentsPath).replace(':id', encodeURIComponent(id)); return axios.get(url); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 67164997bd8..8381b050900 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this, @gitlab/require-i18n-strings */ import $ from 'jquery'; -import _ from 'underscore'; +import { uniq } from 'lodash'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; import { __ } from './locale'; @@ -513,7 +513,7 @@ export class AwardsHandler { addEmojiToFrequentlyUsedList(emoji) { if (this.emoji.isEmojiNameValid(emoji)) { - this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji)); + this.frequentlyUsedEmojis = uniq(this.getFrequentlyUsedEmojis().concat(emoji)); Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 }); } } @@ -522,9 +522,7 @@ export class AwardsHandler { return ( this.frequentlyUsedEmojis || (() => { - const frequentlyUsedEmojis = _.uniq( - (Cookies.get('frequently_used_emojis') || '').split(','), - ); + const frequentlyUsedEmojis = uniq((Cookies.get('frequently_used_emojis') || '').split(',')); this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(inputName => this.emoji.isEmojiNameValid(inputName), ); diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue index 9a30ed93330..056b4ea4aa8 100644 --- a/app/assets/javascripts/blob/components/blob_edit_content.vue +++ b/app/assets/javascripts/blob/components/blob_edit_content.vue @@ -1,5 +1,6 @@ <script> import { initEditorLite } from '~/blob/utils'; +import { debounce } from 'lodash'; export default { props: { @@ -32,16 +33,14 @@ export default { }); }, methods: { - triggerFileChange() { + triggerFileChange: debounce(function debouncedFileChange() { this.$emit('input', this.editor.getValue()); - }, + }, 250), }, }; </script> <template> <div class="file-content code"> - <pre id="editor" ref="editor" data-editor-loading @focusout="triggerFileChange">{{ - value - }}</pre> + <pre id="editor" ref="editor" data-editor-loading @keyup="triggerFileChange">{{ value }}</pre> </div> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue new file mode 100644 index 00000000000..f5c2cc57f3f --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue @@ -0,0 +1,169 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; + +export default { + name: 'CiKeyField', + components: { + GlButton, + GlFormGroup, + GlFormInput, + }, + model: { + prop: 'value', + event: 'input', + }, + props: { + tokenList: { + type: Array, + required: true, + }, + value: { + type: String, + required: true, + }, + }, + data() { + return { + results: [], + arrowCounter: -1, + userDismissedResults: false, + suggestionsId: uniqueId('token-suggestions-'), + }; + }, + computed: { + showAutocomplete() { + return this.showSuggestions ? 'off' : 'on'; + }, + showSuggestions() { + return this.results.length > 0; + }, + }, + mounted() { + document.addEventListener('click', this.handleClickOutside); + }, + destroyed() { + document.removeEventListener('click', this.handleClickOutside); + }, + methods: { + closeSuggestions() { + this.results = []; + this.arrowCounter = -1; + }, + handleClickOutside(event) { + if (!this.$el.contains(event.target)) { + this.closeSuggestions(); + } + }, + onArrowDown() { + const newCount = this.arrowCounter + 1; + + if (newCount >= this.results.length) { + this.arrowCounter = 0; + return; + } + + this.arrowCounter = newCount; + }, + onArrowUp() { + const newCount = this.arrowCounter - 1; + + if (newCount < 0) { + this.arrowCounter = this.results.length - 1; + return; + } + + this.arrowCounter = newCount; + }, + onEnter() { + const currentToken = this.results[this.arrowCounter] || this.value; + this.selectToken(currentToken); + }, + onEsc() { + if (!this.showSuggestions) { + this.$emit('input', ''); + } + this.closeSuggestions(); + this.userDismissedResults = true; + }, + onEntry(value) { + this.$emit('input', value); + this.userDismissedResults = false; + + // short circuit so that we don't false match on empty string + if (value.length < 1) { + this.closeSuggestions(); + return; + } + + const filteredTokens = this.tokenList.filter(token => + token.toLowerCase().includes(value.toLowerCase()), + ); + + if (filteredTokens.length) { + this.openSuggestions(filteredTokens); + } else { + this.closeSuggestions(); + } + }, + openSuggestions(filteredResults) { + this.results = filteredResults; + }, + selectToken(value) { + this.$emit('input', value); + this.closeSuggestions(); + this.$emit('key-selected'); + }, + }, +}; +</script> +<template> + <div> + <div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions"> + <gl-form-group :label="__('Key')" label-for="ci-variable-key"> + <gl-form-input + id="ci-variable-key" + :value="value" + type="text" + role="searchbox" + class="form-control pl-2 js-env-input" + :autocomplete="showAutocomplete" + aria-autocomplete="list" + aria-controls="token-suggestions" + aria-haspopup="listbox" + :aria-expanded="showSuggestions" + data-qa-selector="ci_variable_key_field" + @input="onEntry" + @keydown.down="onArrowDown" + @keydown.up="onArrowUp" + @keydown.enter.prevent="onEnter" + @keydown.esc.stop="onEsc" + @keydown.tab="closeSuggestions" + /> + </gl-form-group> + + <div + v-show="showSuggestions && !userDismissedResults" + id="ci-variable-dropdown" + class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width" + :class="{ 'd-block': showSuggestions }" + > + <div class="dropdown-content"> + <ul :id="suggestionsId"> + <li + v-for="(result, i) in results" + :key="i" + role="option" + :class="{ 'gl-bg-gray-100': i === arrowCounter }" + :aria-selected="i === arrowCounter" + > + <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{ + result + }}</gl-button> + </li> + </ul> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js new file mode 100644 index 00000000000..9022bf51514 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js @@ -0,0 +1,29 @@ +import { __ } from '~/locale'; + +import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants'; + +export const awsTokens = { + [AWS_ACCESS_KEY_ID]: { + name: AWS_ACCESS_KEY_ID, + /* Checks for exactly twenty characters that match key. + Based on greps suggested by Amazon at: + https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ + */ + validation: val => /^[A-Za-z0-9]{20}$/.test(val), + invalidMessage: __('This variable does not match the expected pattern.'), + }, + [AWS_DEFAULT_REGION]: { + name: AWS_DEFAULT_REGION, + }, + [AWS_SECRET_ACCESS_KEY]: { + name: AWS_SECRET_ACCESS_KEY, + /* Checks for exactly forty characters that match secret. + Based on greps suggested by Amazon at: + https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ + */ + validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val), + invalidMessage: __('This variable does not match the expected pattern.'), + }, +}; + +export const awsTokenList = Object.keys(awsTokens); 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 316408adfb2..8f5acd4a0a0 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 @@ -1,8 +1,4 @@ <script> -import { __ } from '~/locale'; -import { mapActions, mapState } from 'vuex'; -import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; -import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { GlDeprecatedButton, GlModal, @@ -14,11 +10,19 @@ import { GlLink, GlIcon, } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; +import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; +import CiKeyField from './ci_key_field.vue'; +import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; export default { modalId: ADD_CI_VARIABLE_MODAL_ID, components: { CiEnvironmentsDropdown, + CiKeyField, GlDeprecatedButton, GlModal, GlFormSelect, @@ -29,6 +33,9 @@ export default { GlLink, GlIcon, }, + mixins: [glFeatureFlagsMixin()], + tokens: awsTokens, + tokenList: awsTokenList, computed: { ...mapState([ 'projectId', @@ -41,23 +48,24 @@ export default { 'selectedEnvironment', ]), canSubmit() { - if (this.variableData.masked && this.maskedState === false) { - return false; - } - return this.variableData.key !== '' && this.variableData.secret_value !== ''; + return ( + this.variableValidationState && + this.variableData.key !== '' && + this.variableData.secret_value !== '' + ); }, canMask() { const regex = RegExp(this.maskableRegex); return regex.test(this.variableData.secret_value); }, displayMaskedError() { - return !this.canMask && this.variableData.masked && this.variableData.secret_value !== ''; + return !this.canMask && this.variableData.masked; }, maskedState() { if (this.displayMaskedError) { return false; } - return null; + return true; }, variableData() { return this.variableBeingEdited || this.variable; @@ -66,7 +74,41 @@ export default { return this.variableBeingEdited ? __('Update variable') : __('Add variable'); }, maskedFeedback() { - return __('This variable can not be masked'); + return this.displayMaskedError ? __('This variable can not be masked.') : ''; + }, + tokenValidationFeedback() { + const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage; + if (!this.tokenValidationState && tokenSpecificFeedback) { + return tokenSpecificFeedback; + } + return ''; + }, + tokenValidationState() { + // If the feature flag is off, do not validate. Remove when flag is removed. + if (!this.glFeatures.ciKeyAutocomplete) { + return true; + } + + const validator = this.$options.tokens?.[this.variableData.key]?.validation; + + if (validator) { + return validator(this.variableData.secret_value); + } + + return true; + }, + variableValidationFeedback() { + return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; + }, + variableValidationState() { + if ( + this.variableData.secret_value === '' || + (this.tokenValidationState && this.maskedState) + ) { + return true; + } + + return false; }, }, methods: { @@ -82,14 +124,13 @@ export default { 'resetSelectedEnvironment', 'setSelectedEnvironment', ]), - updateOrAddVariable() { - if (this.variableBeingEdited) { - this.updateVariable(this.variableBeingEdited); - } else { - this.addVariable(); - } + deleteVarAndClose() { + this.deleteVariable(this.variableBeingEdited); this.hideModal(); }, + hideModal() { + this.$refs.modal.hide(); + }, resetModalHandler() { if (this.variableBeingEdited) { this.resetEditing(); @@ -98,11 +139,12 @@ export default { } this.resetSelectedEnvironment(); }, - hideModal() { - this.$refs.modal.hide(); - }, - deleteVarAndClose() { - this.deleteVariable(this.variableBeingEdited); + updateOrAddVariable() { + if (this.variableBeingEdited) { + this.updateVariable(this.variableBeingEdited); + } else { + this.addVariable(); + } this.hideModal(); }, }, @@ -119,7 +161,13 @@ export default { @hidden="resetModalHandler" > <form> - <gl-form-group :label="__('Key')" label-for="ci-variable-key"> + <ci-key-field + v-if="glFeatures.ciKeyAutocomplete" + v-model="variableData.key" + :token-list="$options.tokenList" + /> + + <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key"> <gl-form-input id="ci-variable-key" v-model="variableData.key" @@ -130,12 +178,14 @@ export default { <gl-form-group :label="__('Value')" label-for="ci-variable-value" - :state="maskedState" - :invalid-feedback="maskedFeedback" + :state="variableValidationState" + :invalid-feedback="variableValidationFeedback" > <gl-form-textarea id="ci-variable-value" + ref="valueField" v-model="variableData.secret_value" + :state="variableValidationState" rows="3" max-rows="6" data-qa-selector="ci_variable_value_field" diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index d22138db102..5fe1e32e37e 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -14,3 +14,8 @@ export const types = { fileType: 'file', allEnvironmentsType: '*', }; + +// AWS TOKEN CONSTANTS +export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID'; +export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION'; +export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 1b11ec355bb..106e15d9382 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -49,6 +49,7 @@ export default class Clusters { installElasticStackPath, installCrossplanePath, installPrometheusPath, + installFluentdPath, managePrometheusPath, clusterEnvironmentsPath, hasRbac, @@ -102,6 +103,7 @@ export default class Clusters { updateKnativeEndpoint: updateKnativePath, installElasticStackEndpoint: installElasticStackPath, clusterEnvironmentsEndpoint: clusterEnvironmentsPath, + installFluentdEndpoint: installFluentdPath, }); this.installApplication = this.installApplication.bind(this); @@ -265,6 +267,7 @@ export default class Clusters { eventHub.$on('setIngressModSecurityEnabled', data => this.setIngressModSecurityEnabled(data)); eventHub.$on('setIngressModSecurityMode', data => this.setIngressModSecurityMode(data)); eventHub.$on('resetIngressModSecurityChanges', id => this.resetIngressModSecurityChanges(id)); + eventHub.$on('setFluentdSettings', data => this.setFluentdSettings(data)); // Add event listener to all the banner close buttons this.addBannerCloseHandler(this.unreachableContainer, 'unreachable'); this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure'); @@ -281,6 +284,7 @@ export default class Clusters { eventHub.$off('setIngressModSecurityEnabled'); eventHub.$off('setIngressModSecurityMode'); eventHub.$off('resetIngressModSecurityChanges'); + eventHub.$off('setFluentdSettings'); } initPolling(method, successCallback, errorCallback) { @@ -506,6 +510,12 @@ export default class Clusters { }); } + setFluentdSettings({ id: appId, port, protocol, host }) { + this.store.updateAppProperty(appId, 'port', port); + this.store.updateAppProperty(appId, 'protocol', protocol); + this.store.updateAppProperty(appId, 'host', host); + } + toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) { if (externalIp !== newExternalIp) { this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp); diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 723030c5b8b..96c00480dfd 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -14,6 +14,7 @@ import knativeLogo from 'images/cluster_app_logos/knative.png'; import meltanoLogo from 'images/cluster_app_logos/meltano.png'; import prometheusLogo from 'images/cluster_app_logos/prometheus.png'; import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png'; +import fluentdLogo from 'images/cluster_app_logos/fluentd.png'; import { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; @@ -22,6 +23,7 @@ import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../con import eventHub from '~/clusters/event_hub'; import CrossplaneProviderStack from './crossplane_provider_stack.vue'; import IngressModsecuritySettings from './ingress_modsecurity_settings.vue'; +import FluentdOutputSettings from './fluentd_output_settings.vue'; export default { components: { @@ -31,6 +33,7 @@ export default { KnativeDomainEditor, CrossplaneProviderStack, IngressModsecuritySettings, + FluentdOutputSettings, }, props: { type: { @@ -102,6 +105,7 @@ export default { meltanoLogo, prometheusLogo, elasticStackLogo, + fluentdLogo, }), computed: { isProjectCluster() { @@ -670,6 +674,41 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity </p> </div> </application-row> + + <application-row + id="fluentd" + :logo-url="fluentdLogo" + :title="applications.fluentd.title" + :status="applications.fluentd.status" + :status-reason="applications.fluentd.statusReason" + :request-status="applications.fluentd.requestStatus" + :request-reason="applications.fluentd.requestReason" + :installed="applications.fluentd.installed" + :install-failed="applications.fluentd.installFailed" + :install-application-request-params="{ + host: applications.fluentd.host, + port: applications.fluentd.port, + protocol: applications.fluentd.protocol, + }" + :uninstallable="applications.fluentd.uninstallable" + :uninstall-successful="applications.fluentd.uninstallSuccessful" + :uninstall-failed="applications.fluentd.uninstallFailed" + :disabled="!helmInstalled" + :updateable="false" + title-link="https://github.com/helm/charts/tree/master/stable/fluentd" + > + <div slot="description"> + <p> + {{ + s__( + `ClusterIntegration|Fluentd is an open source data collector, which lets you unify the data collection and consumption for a better use and understanding of data. Export Web Application Firewall logs to your favorite SIEM.`, + ) + }} + </p> + + <fluentd-output-settings :fluentd="applications.fluentd" /> + </div> + </application-row> </div> </section> </template> diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue new file mode 100644 index 00000000000..97b030927df --- /dev/null +++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue @@ -0,0 +1,159 @@ +<script> +import { __ } from '~/locale'; +import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; +import { GlAlert, GlDeprecatedButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import eventHub from '~/clusters/event_hub'; + +const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS; + +export default { + components: { + GlAlert, + GlDeprecatedButton, + GlDropdown, + GlDropdownItem, + }, + props: { + fluentd: { + type: Object, + required: true, + }, + protocols: { + type: Array, + required: false, + default: () => ['TCP', 'UDP'], + }, + }, + computed: { + isSaving() { + return [UPDATING].includes(this.fluentd.status); + }, + saveButtonDisabled() { + return [UNINSTALLING, UPDATING, INSTALLING].includes(this.fluentd.status); + }, + saveButtonLabel() { + return this.isSaving ? __('Saving') : __('Save changes'); + }, + /** + * Returns true either when: + * - The application is getting updated. + * - The user has changed some of the settings for an application which is + * neither getting installed nor updated. + */ + showButtons() { + return ( + this.isSaving || + (this.fluentd.isEditingSettings && [INSTALLED, UPDATED].includes(this.fluentd.status)) + ); + }, + protocolName() { + if (this.fluentd.protocol !== null && this.fluentd.protocol !== undefined) { + return this.fluentd.protocol.toUpperCase(); + } + return __('Protocol'); + }, + fluentdPort: { + get() { + return this.fluentd.port; + }, + set(port) { + this.setFluentSettings({ port }); + }, + }, + fluentdHost: { + get() { + return this.fluentd.host; + }, + set(host) { + this.setFluentSettings({ host }); + }, + }, + }, + methods: { + updateApplication() { + eventHub.$emit('updateApplication', { + id: FLUENTD, + params: { + port: this.fluentd.port, + protocol: this.fluentd.protocol, + host: this.fluentd.host, + }, + }); + this.resetStatus(); + }, + resetStatus() { + this.fluentd.isEditingSettings = false; + }, + selectProtocol(protocol) { + this.setFluentSettings({ protocol }); + }, + setFluentSettings({ port, protocol, host }) { + this.fluentd.isEditingSettings = true; + const newPort = port !== undefined ? port : this.fluentd.port; + const newProtocol = protocol !== undefined ? protocol : this.fluentd.protocol; + const newHost = host !== undefined ? host : this.fluentd.host; + eventHub.$emit('setFluentdSettings', { + id: FLUENTD, + port: newPort, + protocol: newProtocol, + host: newHost, + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="fluentd.updateFailed" class="mb-3" variant="danger" :dismissible="false"> + {{ + s__( + 'ClusterIntegration|Something went wrong while trying to save your settings. Please try again.', + ) + }} + </gl-alert> + <div class="form-horizontal"> + <div class="form-group"> + <label for="fluentd-host"> + <strong>{{ s__('ClusterIntegration|SIEM Hostname') }}</strong> + </label> + <input id="fluentd-host" v-model="fluentdHost" type="text" class="form-control" /> + </div> + <div class="form-group"> + <label for="fluentd-port"> + <strong>{{ s__('ClusterIntegration|SIEM Port') }}</strong> + </label> + <input id="fluentd-port" v-model="fluentdPort" type="text" class="form-control" /> + </div> + <div class="form-group"> + <label for="fluentd-protocol"> + <strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong> + </label> + <gl-dropdown :text="protocolName" class="w-100"> + <gl-dropdown-item + v-for="(value, index) in protocols" + :key="index" + @click="selectProtocol(value)" + > + {{ value }} + </gl-dropdown-item> + </gl-dropdown> + </div> + <div v-if="showButtons" class="mt-3"> + <gl-deprecated-button + ref="saveBtn" + class="mr-1" + variant="success" + :loading="isSaving" + :disabled="saveButtonDisabled" + @click="updateApplication" + > + {{ saveButtonLabel }} + </gl-deprecated-button> + <gl-deprecated-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus"> + {{ __('Cancel') }} + </gl-deprecated-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 6c3046fc56b..60e179c54eb 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -53,6 +53,7 @@ export const CERT_MANAGER = 'cert_manager'; export const CROSSPLANE = 'crossplane'; export const PROMETHEUS = 'prometheus'; export const ELASTIC_STACK = 'elastic_stack'; +export const FLUENTD = 'fluentd'; export const APPLICATIONS = [ HELM, @@ -63,6 +64,7 @@ export const APPLICATIONS = [ CERT_MANAGER, PROMETHEUS, ELASTIC_STACK, + FLUENTD, ]; export const INGRESS_DOMAIN_SUFFIX = '.nip.io'; diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index 333fb293a15..2a6c6965dab 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -13,6 +13,7 @@ export default class ClusterService { jupyter: this.options.installJupyterEndpoint, knative: this.options.installKnativeEndpoint, elastic_stack: this.options.installElasticStackEndpoint, + fluentd: this.options.installFluentdEndpoint, }; this.appUpdateEndpointMap = { knative: this.options.updateKnativeEndpoint, diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index b09fd6800b6..ca96eb0acea 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -13,6 +13,7 @@ import { UPDATE_EVENT, UNINSTALL_EVENT, ELASTIC_STACK, + FLUENTD, } from '../constants'; import transitionApplicationState from '../services/application_state_machine'; @@ -103,6 +104,14 @@ export default class ClusterStore { ...applicationInitialState, title: s__('ClusterIntegration|Elastic Stack'), }, + fluentd: { + ...applicationInitialState, + title: s__('ClusterIntegration|Fluentd'), + host: null, + port: null, + protocol: null, + isEditingSettings: false, + }, }, environments: [], fetchingEnvironments: false, @@ -253,6 +262,12 @@ export default class ClusterStore { } else if (appId === ELASTIC_STACK) { this.state.applications.elastic_stack.version = version; this.state.applications.elastic_stack.updateAvailable = updateAvailable; + } else if (appId === FLUENTD) { + if (!this.state.applications.fluentd.isEditingSettings) { + this.state.applications.fluentd.port = serverAppEntry.port; + this.state.applications.fluentd.host = serverAppEntry.host; + this.state.applications.fluentd.protocol = serverAppEntry.protocol; + } } }); } diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index ad0f6cc1496..e0d012cef23 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -1,4 +1,3 @@ -import 'underscore'; import './polyfills'; import './jquery'; import './bootstrap'; diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 51879f280e0..41988f321e5 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -1,6 +1,6 @@ import $ from 'jquery'; +import { debounce } from 'lodash'; import Cookies from 'js-cookie'; -import _ from 'underscore'; import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -43,7 +43,7 @@ export default class ContextualSidebar { $(document).trigger('content.resize'); }); - $(window).on('resize', () => _.debounce(this.render(), 100)); + $(window).on('resize', debounce(() => this.render(), 100)); } // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 229612f5e9d..ba585444ba5 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -1,5 +1,5 @@ /* eslint-disable no-new */ -import _ from 'underscore'; +import { debounce } from 'lodash'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; import DropLab from './droplab/drop_lab'; @@ -55,7 +55,7 @@ export default class CreateMergeRequestDropdown { this.isCreatingMergeRequest = false; this.isGettingRef = false; this.mergeRequestCreated = false; - this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500); + this.refDebounce = debounce((value, target) => this.getRef(value, target), 500); this.refIsValid = true; this.refsPath = this.wrapperEl.dataset.refsPath; this.suggestedRef = this.refInput.value; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 6d2b11e39d3..f609ca5f22d 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -59,16 +59,10 @@ export default () => { service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath), }; }, - defaultNumberOfSummaryItems: 3, computed: { currentStage() { return this.store.currentActiveStage(); }, - summaryTableColumnClass() { - return this.state.summary.length === this.$options.defaultNumberOfSummaryItems - ? 'col-sm-3' - : 'col-sm-4'; - }, }, created() { // Conditional check placed here to prevent this method from being called on the diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 9544fbe9fc5..514d26862a3 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -99,8 +99,12 @@ export default { return this.showCommentButton && this.hasDiscussions; }, shouldRenderCommentButton() { - const isDiffHead = parseBoolean(getParameterByName('diff_head')); - return !isDiffHead && this.isLoggedIn && this.showCommentButton; + if (this.isLoggedIn && this.showCommentButton) { + const isDiffHead = parseBoolean(getParameterByName('diff_head')); + return !isDiffHead || gon.features?.mergeRefHeadComments; + } + + return false; }, isMatchLine() { return this.line.type === MATCH_LINE_TYPE; diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index b07dfe5f33d..40e1aec42ed 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -60,3 +60,4 @@ export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines'; export const DIFFS_PER_PAGE = 20; export const DIFF_COMPARE_BASE_VERSION_INDEX = -1; +export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2; diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js index 14c51602f28..dd682060b4b 100644 --- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js +++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js @@ -1,5 +1,6 @@ import { __, n__, sprintf } from '~/locale'; -import { DIFF_COMPARE_BASE_VERSION_INDEX } from '../constants'; +import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { DIFF_COMPARE_BASE_VERSION_INDEX, DIFF_COMPARE_HEAD_VERSION_INDEX } from '../constants'; export const selectedTargetIndex = state => state.startVersion?.version_index || DIFF_COMPARE_BASE_VERSION_INDEX; @@ -9,12 +10,25 @@ export const selectedSourceIndex = state => state.mergeRequestDiff.version_index export const diffCompareDropdownTargetVersions = (state, getters) => { // startVersion only exists if the user has selected a version other // than "base" so if startVersion is null then base must be selected + + const diffHead = parseBoolean(getParameterByName('diff_head')); + const isBaseSelected = !state.startVersion && !diffHead; + const isHeadSelected = !state.startVersion && diffHead; + const baseVersion = { versionName: state.targetBranchName, version_index: DIFF_COMPARE_BASE_VERSION_INDEX, href: state.mergeRequestDiff.base_version_path, isBase: true, - selected: !state.startVersion, + selected: isBaseSelected, + }; + + const headVersion = { + versionName: state.targetBranchName, + version_index: DIFF_COMPARE_HEAD_VERSION_INDEX, + href: state.mergeRequestDiff.head_version_path, + isHead: true, + selected: isHeadSelected, }; // Appended properties here are to make the compare_dropdown_layout easier to reason about const formatVersion = v => { @@ -25,7 +39,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => { ...v, }; }; - return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion]; + return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion]; }; export const diffCompareDropdownSourceVersions = (state, getters) => { diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index cc9bfa2e174..104686993a8 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -182,15 +182,18 @@ export default { [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { const { latestDiff } = state; - const discussionLineCode = discussion.line_code; + const discussionLineCodes = [discussion.line_code, ...(discussion.line_codes || [])]; const fileHash = discussion.diff_file.file_hash; const lineCheck = line => - line.line_code === discussionLineCode && - isDiscussionApplicableToLine({ - discussion, - diffPosition: diffPositionByLineCode[line.line_code], - latestDiff, - }); + discussionLineCodes.some( + discussionLineCode => + line.line_code === discussionLineCode && + isDiscussionApplicableToLine({ + discussion, + diffPosition: diffPositionByLineCode[line.line_code], + latestDiff, + }), + ); const mapDiscussions = (line, extraCheck = () => true) => ({ ...line, discussions: extraCheck() diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 07879ebf7d5..dd8dec49a37 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -440,10 +440,13 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD const { line_code, ...diffPositionCopy } = diffPosition; if (discussion.original_position && discussion.position) { - const originalRefs = discussion.original_position; - const refs = discussion.position; + const discussionPositions = [ + discussion.original_position, + discussion.position, + ...(discussion.positions || []), + ]; - return isEqual(refs, diffPositionCopy) || isEqual(originalRefs, diffPositionCopy); + return discussionPositions.some(position => isEqual(position, diffPositionCopy)); } // eslint-disable-next-line diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index f839e9acf04..490f2330012 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import Dropzone from 'dropzone'; -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import './behaviors/preview_markdown'; import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table'; import csrf from './lib/utils/csrf'; @@ -16,7 +16,7 @@ Dropzone.autoDiscover = false; * @param {String|Object} res */ function getErrorMessage(res) { - if (!res || _.isString(res)) { + if (!res || typeof res === 'string') { return res; } @@ -233,7 +233,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { }; addFileToForm = path => { - $(form).append(`<input type="hidden" name="files[]" value="${_.escape(path)}">`); + $(form).append(`<input type="hidden" name="files[]" value="${esc(path)}">`); }; const showSpinner = () => $uploadingProgressContainer.removeClass('hide'); diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index be2eee828ff..4aad54bed55 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { debounce } from 'lodash'; import axios from './lib/utils/axios_utils'; /** @@ -29,7 +29,7 @@ export default class FilterableList { initSearch() { // Wrap to prevent passing event arguments to .filterResults; - this.debounceFilter = _.debounce(this.onFilterInput.bind(this), 500); + this.debounceFilter = debounce(this.onFilterInput.bind(this), 500); this.unbindEvents(); this.bindEvents(); diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 4d62ec6e385..40d820b1ed5 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import { spriteIcon } from './lib/utils/common_utils'; const FLASH_TYPES = { @@ -39,14 +39,14 @@ const createAction = config => ` class="flash-action" ${config.href ? '' : 'role="button"'} > - ${_.escape(config.title)} + ${esc(config.title)} </a> `; const createFlashEl = (message, type) => ` <div class="flash-${type}"> <div class="flash-text"> - ${_.escape(message)} + ${esc(message)} <div class="close-icon-wrapper js-close-icon"> ${spriteIcon('close', 'close-icon')} </div> diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index b6deedfa5e4..c40b0949e70 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import '@gitlab/at.js'; -import _ from 'underscore'; +import { escape as esc, template } from 'lodash'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; @@ -11,7 +11,7 @@ function sanitize(str) { } export function membersBeforeSave(members) { - return _.map(members, member => { + return members.map(member => { const GROUP_TYPE = 'Group'; let title = ''; @@ -122,7 +122,7 @@ class GfmAutoComplete { cssClasses.push('has-warning'); } - return _.template(tpl)({ + return template(tpl)({ ...value, className: cssClasses.join(' '), }); @@ -137,7 +137,7 @@ class GfmAutoComplete { tpl += '<%- referencePrefix %>'; } } - return _.template(tpl)({ referencePrefix }); + return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix }); }, suffix: '', callbacks: { @@ -692,14 +692,14 @@ GfmAutoComplete.Emoji = { // Team Members GfmAutoComplete.Members = { templateFunction({ avatarTag, username, title, icon }) { - return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small> ${icon}</li>`; + return `<li>${avatarTag} ${username} <small>${esc(title)}</small> ${icon}</li>`; }, }; GfmAutoComplete.Labels = { templateFunction(color, title) { - return `<li><span class="dropdown-label-box" style="background: ${_.escape( - color, - )}"></span> ${_.escape(title)}</li>`; + return `<li><span class="dropdown-label-box" style="background: ${esc(color)}"></span> ${esc( + title, + )}</li>`; }, }; // Issues, MergeRequests and Snippets @@ -709,13 +709,13 @@ GfmAutoComplete.Issues = { return value.reference || '${atwho-at}${id}'; }, templateFunction({ id, title, reference }) { - return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`; + return `<li><small>${reference || id}</small> ${esc(title)}</li>`; }, }; // Milestones GfmAutoComplete.Milestones = { templateFunction(title) { - return `<li>${_.escape(title)}</li>`; + return `<li>${esc(title)}</li>`; }, }; GfmAutoComplete.Loading = { diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 918276ce329..d9191d48d8f 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,7 +1,7 @@ /* eslint-disable max-classes-per-file, one-var, consistent-return */ import $ from 'jquery'; -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import axios from './lib/utils/axios_utils'; import { visitUrl } from './lib/utils/url_utility'; @@ -145,7 +145,7 @@ class GitLabDropdownFilter { // { prop: 'foo' }, // { prop: 'baz' } // ] - if (_.isArray(data)) { + if (Array.isArray(data)) { results = fuzzaldrinPlus.filter(data, searchText, { key: this.options.keys, }); @@ -261,14 +261,14 @@ class GitLabDropdown { // If no input is passed create a default one self = this; // If selector was passed - if (_.isString(this.filterInput)) { + if (typeof this.filterInput === 'string') { this.filterInput = this.getElement(this.filterInput); } const searchFields = this.options.search ? this.options.search.fields : []; if (this.options.data) { // If we provided data // data could be an array of objects or a group of arrays - if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { + if (typeof this.options.data === 'object' && !(this.options.data instanceof Function)) { this.fullData = this.options.data; currentIndex = -1; this.parseData(this.options.data); @@ -610,7 +610,7 @@ class GitLabDropdown { // eslint-disable-next-line class-methods-use-this highlightTemplate(text, template) { - return `"<b>${_.escape(text)}</b>" ${template}`; + return `"<b>${esc(text)}</b>" ${template}`; } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 1811a942beb..ced10fff129 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -29,6 +29,10 @@ export default class GLForm { if (this.autoComplete) { this.autoComplete.destroy(); } + if (this.formDropzone) { + this.formDropzone.destroy(); + } + this.form.data('glForm', null); } @@ -45,7 +49,7 @@ export default class GLForm { ); this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM); - dropzoneInput(this.form, { parallelUploads: 1 }); + this.formDropzone = dropzoneInput(this.form, { parallelUploads: 1 }); autosize(this.textarea); } // form and textarea event listeners diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index f0f5b8395c9..c7acc21378b 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -32,7 +32,7 @@ export default { }, methods: { change(page) { - const filterGroupsParam = getParameterByName('filter_groups'); + const filterGroupsParam = getParameterByName('filter'); const sortParam = getParameterByName('sort'); const archivedParam = getParameterByName('archived'); eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam); diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 7ebcacc530f..40d36063391 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -12,6 +12,7 @@ import RepoEditor from './repo_editor.vue'; import RightPane from './panes/right.vue'; import ErrorMessage from './error_message.vue'; import CommitEditorHeader from './commit_sidebar/editor_header.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -26,6 +27,7 @@ export default { GlDeprecatedButton, GlLoadingIcon, }, + mixins: [glFeatureFlagsMixin()], props: { rightPaneComponent: { type: Vue.Component, @@ -52,10 +54,17 @@ export default { 'allBlobs', 'emptyRepo', 'currentTree', + 'editorTheme', ]), + themeName() { + return this.glFeatures.webideDarkTheme && window.gon?.user_color_scheme; + }, }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); + + if (this.themeName) + document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`); }, methods: { ...mapActions(['toggleFileFinder', 'openNewEntryModal']), @@ -77,7 +86,10 @@ export default { </script> <template> - <article class="ide position-relative d-flex flex-column align-items-stretch"> + <article + class="ide position-relative d-flex flex-column align-items-stretch" + :class="{ [`theme-${themeName}`]: themeName }" + > <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> <find-file diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 1ffd5c61282..35d54816350 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import _ from 'underscore'; +import { escape as esc } from 'lodash'; import { __, sprintf } from './locale'; import axios from './lib/utils/axios_utils'; import flash from './flash'; @@ -73,9 +73,9 @@ class ImporterStatus { const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing'); job.find('.import-actions').html( sprintf( - _.escape(__('%{loadingIcon} Started')), + esc(__('%{loadingIcon} Started')), { - loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${_.escape( + loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${esc( connectingVerb, )}"></i>`, }, diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index 45de287d44d..95e10cc75cc 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,7 +1,7 @@ /* eslint-disable consistent-return, func-names, array-callback-return */ import $ from 'jquery'; -import _ from 'underscore'; +import { intersection } from 'lodash'; import axios from './lib/utils/axios_utils'; import Flash from './flash'; import { __ } from './locale'; @@ -111,7 +111,7 @@ export default { this.getElement('.selected-issuable:checked').each((i, el) => { labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); }); - return _.intersection.apply(this, labelIds); + return intersection.apply(this, labelIds); }, // From issuable's initial bulk selection @@ -120,7 +120,7 @@ export default { this.getElement('.selected-issuable:checked').each((i, el) => { labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); }); - return _.intersection.apply(this, labelIds); + return intersection.apply(this, labelIds); }, // From issuable's initial bulk selection @@ -144,7 +144,7 @@ export default { // Add uniqueIds to add it as argument for _.intersection labelIds.unshift(uniqueIds); // Return IDs that are present but not in all selected issueables - return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); + return uniqueIds.filter(x => !intersection.apply(this, labelIds).includes(x)); }, getElement(selector) { diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index bd6e8433544..50562688c53 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -1,7 +1,7 @@ /* eslint-disable class-methods-use-this, no-new */ import $ from 'jquery'; -import { property } from 'underscore'; +import { property } from 'lodash'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import MilestoneSelect from './milestone_select'; import issueStatusSelect from './issue_status_select'; diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 9136a47d542..f0967e77faf 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -12,6 +12,8 @@ export default class Issue { constructor() { if ($('a.btn-close').length) this.initIssueBtnEventListeners(); + if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener(); + Issue.$btnNewBranch = $('#new-branch'); Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); @@ -89,7 +91,7 @@ export default class Issue { return $(document).on( 'click', - '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', + '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen, a.btn-close-anyway', e => { e.preventDefault(); e.stopImmediatePropagation(); @@ -99,19 +101,30 @@ export default class Issue { Issue.submitNoteForm($button.closest('form')); } - this.disableCloseReopenButton($button); - - const url = $button.attr('href'); - return axios - .put(url) - .then(({ data }) => { - const isClosed = $button.hasClass('btn-close'); - this.updateTopState(isClosed, data); - }) - .catch(() => flash(issueFailMessage)) - .then(() => { - this.disableCloseReopenButton($button, false); - }); + const shouldDisplayBlockedWarning = $button.hasClass('btn-issue-blocked'); + const warningBanner = $('.js-close-blocked-issue-warning'); + if (shouldDisplayBlockedWarning) { + this.toggleWarningAndCloseButton(); + } else { + this.disableCloseReopenButton($button); + + const url = $button.attr('href'); + return axios + .put(url) + .then(({ data }) => { + const isClosed = $button.is('.btn-close, .btn-close-anyway'); + this.updateTopState(isClosed, data); + if ($button.hasClass('btn-close-anyway')) { + warningBanner.addClass('hidden'); + if (this.closeReopenReportToggle) + $('.js-issuable-close-dropdown').removeClass('hidden'); + } + }) + .catch(() => flash(issueFailMessage)) + .then(() => { + this.disableCloseReopenButton($button, false); + }); + } }, ); } @@ -137,6 +150,23 @@ export default class Issue { this.reopenButtons.toggleClass('hidden', !isClosed); } + toggleWarningAndCloseButton() { + const warningBanner = $('.js-close-blocked-issue-warning'); + warningBanner.toggleClass('hidden'); + $('.btn-close').toggleClass('hidden'); + if (this.closeReopenReportToggle) { + $('.js-issuable-close-dropdown').toggleClass('hidden'); + } + } + + initIssueWarningBtnEventListener() { + return $(document).on('click', '.js-close-blocked-issue-warning button.btn-secondary', e => { + e.preventDefault(); + e.stopImmediatePropagation(); + this.toggleWarningAndCloseButton(); + }); + } + static submitNoteForm(form) { const noteText = form.find('textarea.js-note-text').val(); if (noteText && noteText.trim().length > 0) { diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue index 437239ce0be..b71c06e4217 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_app.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue @@ -1,12 +1,20 @@ <script> -import getJiraProjects from '../queries/getJiraProjects.query.graphql'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; +import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql'; +import { IMPORT_STATE, isInProgress } from '../utils'; import JiraImportForm from './jira_import_form.vue'; +import JiraImportProgress from './jira_import_progress.vue'; import JiraImportSetup from './jira_import_setup.vue'; export default { name: 'JiraImportApp', components: { + GlAlert, + GlLoadingIcon, JiraImportForm, + JiraImportProgress, JiraImportSetup, }, props: { @@ -14,6 +22,18 @@ export default { type: Boolean, required: true, }, + inProgressIllustration: { + type: String, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + jiraProjects: { + type: Array, + required: true, + }, projectPath: { type: String, required: true, @@ -23,26 +43,111 @@ export default { required: true, }, }, + data() { + return { + errorMessage: '', + showAlert: false, + }; + }, apollo: { - getJiraImports: { - query: getJiraProjects, + jiraImportDetails: { + query: getJiraImportDetailsQuery, variables() { return { fullPath: this.projectPath, }; }, - update: data => data.project.jiraImports, + update: ({ project }) => ({ + status: project.jiraImportStatus, + import: project.jiraImports.nodes[0], + }), skip() { return !this.isJiraConfigured; }, }, }, + computed: { + isImportInProgress() { + return isInProgress(this.jiraImportDetails?.status); + }, + jiraProjectsOptions() { + return this.jiraProjects.map(([text, value]) => ({ text, value })); + }, + }, + methods: { + dismissAlert() { + this.showAlert = false; + }, + initiateJiraImport(project) { + this.$apollo + .mutate({ + mutation: initiateJiraImportMutation, + variables: { + input: { + projectPath: this.projectPath, + jiraProjectKey: project, + }, + }, + update: (store, { data }) => { + if (data.jiraImportStart.errors.length) { + return; + } + + store.writeQuery({ + query: getJiraImportDetailsQuery, + variables: { + fullPath: this.projectPath, + }, + data: { + project: { + jiraImportStatus: IMPORT_STATE.SCHEDULED, + jiraImports: { + nodes: [data.jiraImportStart.jiraImport], + __typename: 'JiraImportConnection', + }, + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Project', + }, + }, + }); + }, + }) + .then(({ data }) => { + if (data.jiraImportStart.errors.length) { + this.setAlertMessage(data.jiraImportStart.errors.join('. ')); + } + }) + .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.'))); + }, + setAlertMessage(message) { + this.errorMessage = message; + this.showAlert = true; + }, + }, }; </script> <template> <div> + <gl-alert v-if="showAlert" variant="danger" @dismiss="dismissAlert"> + {{ errorMessage }} + </gl-alert> + <jira-import-setup v-if="!isJiraConfigured" :illustration="setupIllustration" /> - <jira-import-form v-else /> + <gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" /> + <jira-import-progress + v-else-if="isImportInProgress" + :illustration="inProgressIllustration" + :import-initiator="jiraImportDetails.import.scheduledBy.name" + :import-project="jiraImportDetails.import.jiraProjectKey" + :import-time="jiraImportDetails.import.scheduledAt" + :issues-path="issuesPath" + /> + <jira-import-form + v-else + :issues-path="issuesPath" + :jira-projects="jiraProjectsOptions" + @initiateJiraImport="initiateJiraImport" + /> </div> </template> diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index 26e51c02b41..0146f564260 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -12,6 +12,39 @@ export default { }, currentUserAvatarUrl: gon.current_user_avatar_url, currentUsername: gon.current_username, + props: { + issuesPath: { + type: String, + required: true, + }, + jiraProjects: { + type: Array, + required: true, + }, + }, + data() { + return { + selectedOption: null, + selectState: null, + }; + }, + methods: { + initiateJiraImport(event) { + event.preventDefault(); + if (!this.selectedOption) { + this.showValidationError(); + } else { + this.hideValidationError(); + this.$emit('initiateJiraImport', this.selectedOption); + } + }, + hideValidationError() { + this.selectState = null; + }, + showValidationError() { + this.selectState = false; + }, + }, }; </script> @@ -19,14 +52,21 @@ export default { <div> <h3 class="page-title">{{ __('New Jira import') }}</h3> <hr /> - <form> + <form @submit="initiateJiraImport"> <gl-form-group class="row align-items-center" + :invalid-feedback="__('Please select a Jira project')" :label="__('Import from')" label-cols-sm="2" label-for="jira-project-select" > - <gl-form-select id="jira-project-select" class="mb-2" /> + <gl-form-select + id="jira-project-select" + v-model="selectedOption" + class="mb-2" + :options="jiraProjects" + :state="selectState" + /> </gl-form-group> <gl-form-group @@ -86,8 +126,10 @@ export default { </gl-form-group> <div class="footer-block row-content-block d-flex justify-content-between"> - <gl-button category="primary" variant="success">{{ __('Next') }}</gl-button> - <gl-button>{{ __('Cancel') }}</gl-button> + <gl-button type="submit" category="primary" variant="success" class="js-no-auto-disable"> + {{ __('Next') }} + </gl-button> + <gl-button :href="issuesPath">{{ __('Cancel') }}</gl-button> </div> </form> </div> diff --git a/app/assets/javascripts/jira_import/components/jira_import_progress.vue b/app/assets/javascripts/jira_import/components/jira_import_progress.vue new file mode 100644 index 00000000000..2d610224658 --- /dev/null +++ b/app/assets/javascripts/jira_import/components/jira_import_progress.vue @@ -0,0 +1,66 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; + +export default { + name: 'JiraImportProgress', + components: { + GlEmptyState, + }, + props: { + illustration: { + type: String, + required: true, + }, + importInitiator: { + type: String, + required: true, + }, + importProject: { + type: String, + required: true, + }, + importTime: { + type: String, + required: true, + }, + issuesPath: { + type: String, + required: true, + }, + }, + computed: { + importInitiatorText() { + return sprintf(__('Import started by: %{importInitiator}'), { + importInitiator: this.importInitiator, + }); + }, + importProjectText() { + return sprintf(__('Jira project: %{importProject}'), { + importProject: this.importProject, + }); + }, + importTimeText() { + return sprintf(__('Time of import: %{importTime}'), { + importTime: formatDate(this.importTime), + }); + }, + }, +}; +</script> + +<template> + <gl-empty-state + :svg-path="illustration" + :title="__('Import in progress')" + :primary-button-text="__('View issues')" + :primary-button-link="issuesPath" + > + <template #description> + <p class="mb-0">{{ importInitiatorText }}</p> + <p class="mb-0">{{ importTimeText }}</p> + <p class="mb-0">{{ importProjectText }}</p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/jira_import/components/jira_import_setup.vue b/app/assets/javascripts/jira_import/components/jira_import_setup.vue index 917930397f4..44773a773d5 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_setup.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_setup.vue @@ -1,6 +1,11 @@ <script> +import { GlEmptyState } from '@gitlab/ui'; + export default { name: 'JiraImportSetup', + components: { + GlEmptyState, + }, props: { illustration: { type: String, @@ -11,15 +16,11 @@ export default { </script> <template> - <div class="empty-state"> - <div class="svg-content"> - <img :src="illustration" :alt="__('Set up Jira Integration illustration')" /> - </div> - <div class="text-content d-flex flex-column align-items-center"> - <p>{{ __('You will first need to set up Jira Integration to use this feature.') }}</p> - <a class="btn btn-success" href="../services/jira/edit"> - {{ __('Set up Jira Integration') }} - </a> - </div> - </div> + <gl-empty-state + :svg-path="illustration" + title="" + :description="__('You will first need to set up Jira Integration to use this feature.')" + :primary-button-text="__('Set up Jira Integration')" + primary-button-link="../services/jira/edit" + /> </template> diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index 13b16b81c49..8bd70e4e277 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -24,7 +24,10 @@ export default function mountJiraImportApp() { render(createComponent) { return createComponent(App, { props: { + inProgressIllustration: el.dataset.inProgressIllustration, isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), + issuesPath: el.dataset.issuesPath, + jiraProjects: el.dataset.jiraProjects ? JSON.parse(el.dataset.jiraProjects) : [], projectPath: el.dataset.projectPath, setupIllustration: el.dataset.setupIllustration, }, diff --git a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql b/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql deleted file mode 100644 index 13100eac221..00000000000 --- a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql +++ /dev/null @@ -1,14 +0,0 @@ -query getJiraProjects($fullPath: ID!) { - project(fullPath: $fullPath) { - jiraImportStatus - jiraImports { - nodes { - jiraProjectKey - scheduledAt - scheduledBy { - username - } - } - } - } -} diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql new file mode 100644 index 00000000000..0eaaad580fc --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql @@ -0,0 +1,12 @@ +#import "./jira_import.fragment.graphql" + +query($fullPath: ID!) { + project(fullPath: $fullPath) { + jiraImportStatus + jiraImports(last: 1) { + nodes { + ...JiraImport + } + } + } +} diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql new file mode 100644 index 00000000000..8fda8287988 --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql @@ -0,0 +1,11 @@ +#import "./jira_import.fragment.graphql" + +mutation($input: JiraImportStartInput!) { + jiraImportStart(input: $input) { + clientMutationId + jiraImport { + ...JiraImport + } + errors + } +} diff --git a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql new file mode 100644 index 00000000000..fde2ebeff91 --- /dev/null +++ b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql @@ -0,0 +1,7 @@ +fragment JiraImport on JiraImport { + jiraProjectKey + scheduledAt + scheduledBy { + name + } +} diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils.js new file mode 100644 index 00000000000..504cf19e44e --- /dev/null +++ b/app/assets/javascripts/jira_import/utils.js @@ -0,0 +1,10 @@ +export const IMPORT_STATE = { + FAILED: 'failed', + FINISHED: 'finished', + NONE: 'none', + SCHEDULED: 'scheduled', + STARTED: 'started', +}; + +export const isInProgress = state => + state === IMPORT_STATE.SCHEDULED || state === IMPORT_STATE.STARTED; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 7107c970457..47d5a8253dd 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -3,7 +3,7 @@ /* global ListLabel */ import $ from 'jquery'; -import _ from 'underscore'; +import { isEqual, escape as esc, sortBy, template } from 'lodash'; import { sprintf, s__, __ } from './locale'; import axios from './lib/utils/axios_utils'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; @@ -76,7 +76,7 @@ export default class LabelsSelect { }) .get(); - if (_.isEqual(initialSelected, selected)) return; + if (isEqual(initialSelected, selected)) return; initialSelected = selected; const data = {}; @@ -101,7 +101,7 @@ export default class LabelsSelect { let labelCount = 0; if (data.labels.length && issueUpdateURL) { template = LabelsSelect.getLabelTemplate({ - labels: _.sortBy(data.labels, 'title'), + labels: sortBy(data.labels, 'title'), issueUpdateURL, enableScopedLabels: scopedLabels, scopedLabelsDocumentationLink, @@ -269,7 +269,7 @@ export default class LabelsSelect { } linkEl.className = selectedClass.join(' '); - linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`; + linkEl.innerHTML = `${colorEl} ${esc(label.title)}`; const listItemEl = document.createElement('li'); listItemEl.appendChild(linkEl); @@ -436,7 +436,7 @@ export default class LabelsSelect { if (isScopedLabel(label)) { const prevIds = oldLabels.map(label => label.id); const newIds = boardsStore.detail.issue.labels.map(label => label.id); - const differentIds = _.difference(prevIds, newIds); + const differentIds = prevIds.filter(x => !newIds.includes(x)); $dropdown.data('marked', newIds); $dropdownMenu .find(differentIds.map(id => `[data-label-id="${id}"]`).join(',')) @@ -483,7 +483,7 @@ export default class LabelsSelect { '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>" class="gl-link gl-label-link has-tooltip" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>">'; const spanOpenTag = '<span class="gl-label-text" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">'; - const labelTemplate = _.template( + const labelTemplate = template( [ '<span class="gl-label">', linkOpenTag, @@ -499,7 +499,7 @@ export default class LabelsSelect { return escapeStr(label.text_color === '#FFFFFF' ? label.color : label.text_color); }; - const infoIconTemplate = _.template( + const infoIconTemplate = template( [ '<a href="<%= scopedLabelsDocumentationLink %>" class="gl-link gl-label-icon" target="_blank" rel="noopener">', '<i class="fa fa-question-circle"></i>', @@ -507,7 +507,7 @@ export default class LabelsSelect { ].join(''), ); - const scopedLabelTemplate = _.template( + const scopedLabelTemplate = template( [ '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>;">', linkOpenTag, @@ -523,7 +523,7 @@ export default class LabelsSelect { ].join(''), ); - const tooltipTitleTemplate = _.template( + const tooltipTitleTemplate = template( [ '<% if (isScopedLabel(label) && enableScopedLabels) { %>', "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>", @@ -535,9 +535,9 @@ export default class LabelsSelect { ].join(''), ); - const tpl = _.template( + const tpl = template( [ - '<% _.each(labels, function(label){ %>', + '<% labels.forEach(function(label){ %>', '<% if (isScopedLabel(label) && enableScopedLabels) { %>', '<span class="d-inline-block position-relative scoped-label-wrapper">', '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, infoIconTemplate, scopedLabelsDocumentationLink, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', @@ -557,7 +557,7 @@ export default class LabelsSelect { scopedLabelTemplate, tooltipTitleTemplate, isScopedLabel, - escapeStr: _.escape, + escapeStr: esc, }); } diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index 9e8edd05b88..a464290ffb5 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { debounce, throttle } from 'lodash'; export const placeholderImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; @@ -82,7 +82,7 @@ export default class LazyLoader { } startIntersectionObserver = () => { - this.throttledElementsInView = _.throttle(() => this.checkElementsInView(), 300); + this.throttledElementsInView = throttle(() => this.checkElementsInView(), 300); this.intersectionObserver = new IntersectionObserver(this.onIntersection, { rootMargin: `${SCROLL_THRESHOLD}px 0px`, thresholds: 0.1, @@ -102,8 +102,8 @@ export default class LazyLoader { }; startLegacyObserver() { - this.throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300); - this.debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300); + this.throttledScrollCheck = throttle(() => this.scrollCheck(), 300); + this.debouncedElementsInView = debounce(() => this.checkElementsInView(), 300); window.addEventListener('scroll', this.throttledScrollCheck); window.addEventListener('resize', this.debouncedElementsInView); } diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js index d3aea37e677..adf374db66c 100644 --- a/app/assets/javascripts/lib/utils/unit_format/index.js +++ b/app/assets/javascripts/lib/utils/unit_format/index.js @@ -1,3 +1,4 @@ +import { engineeringNotation } from '@gitlab/ui/src/utils/number_utils'; import { s__ } from '~/locale'; import { @@ -39,15 +40,18 @@ export const SUPPORTED_FORMATS = { gibibytes: 'gibibytes', tebibytes: 'tebibytes', pebibytes: 'pebibytes', + + // Engineering Notation + engineering: 'engineering', }; /** * Returns a function that formats number to different units - * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to number. + * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation. * * */ -export const getFormatter = (format = SUPPORTED_FORMATS.number) => { +export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { // Number if (format === SUPPORTED_FORMATS.number) { @@ -252,6 +256,17 @@ export const getFormatter = (format = SUPPORTED_FORMATS.number) => { return scaledBinaryFormatter('B', 5); } + if (format === SUPPORTED_FORMATS.engineering) { + /** + * Formats via engineering notation + * + * @function + * @param {Number} value - Value to format + * @param {Number} fractionDigits - precision decimals - Defaults to 2 + */ + return engineeringNotation; + } + // Fail so client library addresses issue throw TypeError(`${format} is not a valid number format`); }; diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js index 7ab4e725d99..b4658a159d7 100644 --- a/app/assets/javascripts/locale/sprintf.js +++ b/app/assets/javascripts/locale/sprintf.js @@ -5,7 +5,7 @@ import { escape } from 'lodash'; @param input (translated) text with parameters (e.g. '%{num_users} users use us') @param {Object} parameters object mapping parameter names to values (e.g. { num_users: 5 }) - @param {Boolean} escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape) + @param {Boolean} escapeParameters whether parameter values should be escaped (see https://lodash.com/docs/4.17.15#escape) @returns {String} the text with parameters replaces (e.g. '5 users use us') @see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 81b2e9f13a5..6c8f6372795 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -298,6 +298,18 @@ document.addEventListener('DOMContentLoaded', () => { if ($gutterIcon.hasClass('fa-angle-double-right')) { $sidebarGutterToggle.trigger('click'); } + + const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle'); + + // Sidebar has an icon which corresponds to collapsing the sidebar + // only then trigger the click. + if (sidebarGutterVueToggleEl) { + const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right'); + + if (collapseIcon) { + collapseIcon.click(); + } + } } }); diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index d15e4ecb537..5d2825e3cd2 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -3,7 +3,7 @@ /* global ListMilestone */ import $ from 'jquery'; -import _ from 'underscore'; +import { template, escape as esc } from 'lodash'; import { __ } from '~/locale'; import '~/gl_dropdown'; import axios from './lib/utils/axios_utils'; @@ -60,7 +60,7 @@ export default class MilestoneSelect { selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault; if (issueUpdateURL) { - milestoneLinkTemplate = _.template( + milestoneLinkTemplate = template( '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>', ); milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`; @@ -106,12 +106,12 @@ export default class MilestoneSelect { if (showMenuAbove) { $dropdown.data('glDropdown').positionMenuAbove(); } - $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active'); + $(`[data-milestone-id="${esc(selectedMilestone)}"] > a`).addClass('is-active'); }), renderRow: milestone => ` - <li data-milestone-id="${_.escape(milestone.name)}"> + <li data-milestone-id="${esc(milestone.name)}"> <a href='#' class='dropdown-menu-milestone-link'> - ${_.escape(milestone.title)} + ${esc(milestone.title)} </a> </li> `, @@ -129,7 +129,7 @@ export default class MilestoneSelect { }, defaultLabel, fieldName: $dropdown.data('fieldName'), - text: milestone => _.escape(milestone.title), + text: milestone => esc(milestone.title), id: milestone => { if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) { return milestone.name; @@ -148,7 +148,7 @@ export default class MilestoneSelect { selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; } $('a.is-active', $el).removeClass('is-active'); - $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active'); + $(`[data-milestone-id="${esc(selectedMilestone)}"] > a`, $el).addClass('is-active'); }, vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: clickEvent => { diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue new file mode 100644 index 00000000000..2c6223c5dd7 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/alert_widget.vue @@ -0,0 +1,286 @@ +<script> +import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import createFlash from '~/flash'; +import AlertWidgetForm from './alert_widget_form.vue'; +import AlertsService from '../services/alerts_service'; +import { alertsValidator, queriesValidator } from '../validators'; +import { OPERATORS } from '../constants'; +import { values, get } from 'lodash'; + +export default { + components: { + AlertWidgetForm, + GlBadge, + GlLoadingIcon, + GlIcon, + GlTooltip, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + alertsEndpoint: { + type: String, + required: true, + }, + showLoadingState: { + type: Boolean, + required: false, + default: true, + }, + // { [alertPath]: { alert_attributes } }. Populated from subsequent API calls. + // Includes only the metrics/alerts to be managed by this widget. + alertsToManage: { + type: Object, + required: false, + default: () => ({}), + validator: alertsValidator, + }, + // [{ metric+query_attributes }]. Represents queries (and alerts) we know about + // on intial fetch. Essentially used for reference. + relevantQueries: { + type: Array, + required: true, + validator: queriesValidator, + }, + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + service: null, + errorMessage: null, + isLoading: false, + apiAction: 'create', + }; + }, + i18n: { + alertsCountMsg: s__('PrometheusAlerts|%{count} alerts applied'), + singleFiringMsg: s__('PrometheusAlerts|Firing: %{alert}'), + multipleFiringMsg: s__('PrometheusAlerts|%{firingCount} firing'), + firingAlertsTooltip: s__('PrometheusAlerts|Firing: %{alerts}'), + }, + computed: { + singleAlertSummary() { + return { + message: this.isFiring ? this.$options.i18n.singleFiringMsg : this.thresholds[0], + alert: this.thresholds[0], + }; + }, + multipleAlertsSummary() { + return { + message: this.isFiring + ? `${this.$options.i18n.alertsCountMsg}, ${this.$options.i18n.multipleFiringMsg}` + : this.$options.i18n.alertsCountMsg, + count: this.thresholds.length, + firingCount: this.firingAlerts.length, + }; + }, + shouldShowLoadingIcon() { + return this.showLoadingState && this.isLoading; + }, + thresholds() { + const alertsToManage = Object.keys(this.alertsToManage); + return alertsToManage.map(this.formatAlertSummary); + }, + hasAlerts() { + return Boolean(Object.keys(this.alertsToManage).length); + }, + hasMultipleAlerts() { + return this.thresholds.length > 1; + }, + isFiring() { + return Boolean(this.firingAlerts.length); + }, + firingAlerts() { + return values(this.alertsToManage).filter(alert => + this.passedAlertThreshold(this.getQueryData(alert), alert), + ); + }, + formattedFiringAlerts() { + return this.firingAlerts.map(alert => this.formatAlertSummary(alert.alert_path)); + }, + configuredAlert() { + return this.hasAlerts ? values(this.alertsToManage)[0].metricId : ''; + }, + }, + created() { + this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint }); + this.fetchAlertData(); + }, + methods: { + fetchAlertData() { + this.isLoading = true; + + const queriesWithAlerts = this.relevantQueries.filter(query => query.alert_path); + + return Promise.all( + queriesWithAlerts.map(query => + this.service + .readAlert(query.alert_path) + .then(alertAttributes => this.setAlert(alertAttributes, query.metricId)), + ), + ) + .then(() => { + this.isLoading = false; + }) + .catch(() => { + createFlash(s__('PrometheusAlerts|Error fetching alert')); + this.isLoading = false; + }); + }, + setAlert(alertAttributes, metricId) { + this.$emit('setAlerts', alertAttributes.alert_path, { ...alertAttributes, metricId }); + }, + removeAlert(alertPath) { + this.$emit('setAlerts', alertPath, null); + }, + formatAlertSummary(alertPath) { + const alert = this.alertsToManage[alertPath]; + const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId); + + return `${alertQuery.label} ${alert.operator} ${alert.threshold}`; + }, + passedAlertThreshold(data, alert) { + const { threshold, operator } = alert; + + switch (operator) { + case OPERATORS.greaterThan: + return data.some(value => value > threshold); + case OPERATORS.lessThan: + return data.some(value => value < threshold); + case OPERATORS.equalTo: + return data.some(value => value === threshold); + default: + return false; + } + }, + getQueryData(alert) { + const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId); + + return get(alertQuery, 'result[0].values', []).map(value => get(value, '[1]', null)); + }, + showModal() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + hideModal() { + this.errorMessage = null; + this.$root.$emit('bv::hide::modal', this.modalId); + }, + handleSetApiAction(apiAction) { + this.apiAction = apiAction; + }, + handleCreate({ operator, threshold, prometheus_metric_id }) { + const newAlert = { operator, threshold, prometheus_metric_id }; + this.isLoading = true; + this.service + .createAlert(newAlert) + .then(alertAttributes => { + this.setAlert(alertAttributes, prometheus_metric_id); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error creating alert'); + this.isLoading = false; + }); + }, + handleUpdate({ alert, operator, threshold }) { + const updatedAlert = { operator, threshold }; + this.isLoading = true; + this.service + .updateAlert(alert, updatedAlert) + .then(alertAttributes => { + this.setAlert(alertAttributes, this.alertsToManage[alert].metricId); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error saving alert'); + this.isLoading = false; + }); + }, + handleDelete({ alert }) { + this.isLoading = true; + this.service + .deleteAlert(alert) + .then(() => { + this.removeAlert(alert); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error deleting alert'); + this.isLoading = false; + }); + }, + }, +}; +</script> + +<template> + <div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden"> + <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" /> + <span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{ + errorMessage + }}</span> + <span + v-else-if="hasAlerts" + ref="alertCurrentSetting" + class="alert-current-setting cursor-pointer d-flex" + @click="showModal" + > + <gl-badge + :variant="isFiring ? 'danger' : 'secondary'" + pill + class="d-flex-center text-truncate" + > + <gl-icon name="warning" :size="16" class="flex-shrink-0" /> + <span class="text-truncate gl-pl-1"> + <gl-sprintf + :message=" + hasMultipleAlerts ? multipleAlertsSummary.message : singleAlertSummary.message + " + > + <template #alert> + {{ singleAlertSummary.alert }} + </template> + <template #count> + {{ multipleAlertsSummary.count }} + </template> + <template #firingCount> + {{ multipleAlertsSummary.firingCount }} + </template> + </gl-sprintf> + </span> + </gl-badge> + <gl-tooltip v-if="hasMultipleAlerts && isFiring" :target="() => $refs.alertCurrentSetting"> + <gl-sprintf :message="$options.i18n.firingAlertsTooltip"> + <template #alerts> + <div v-for="alert in formattedFiringAlerts" :key="alert.alert_path"> + {{ alert }} + </div> + </template> + </gl-sprintf> + </gl-tooltip> + </span> + <alert-widget-form + ref="widgetForm" + :disabled="isLoading" + :alerts-to-manage="alertsToManage" + :relevant-queries="relevantQueries" + :error-message="errorMessage" + :configured-alert="configuredAlert" + :modal-id="modalId" + @create="handleCreate" + @update="handleUpdate" + @delete="handleDelete" + @cancel="hideModal" + @setAction="handleSetApiAction" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue new file mode 100644 index 00000000000..860d854b5ae --- /dev/null +++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue @@ -0,0 +1,300 @@ +<script> +import { isEmpty, findKey } from 'lodash'; +import Vue from 'vue'; +import { + GlLink, + GlDeprecatedButton, + GlButtonGroup, + GlFormGroup, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlModal, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import Translate from '~/vue_shared/translate'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import Icon from '~/vue_shared/components/icon.vue'; +import { alertsValidator, queriesValidator } from '../validators'; +import { OPERATORS } from '../constants'; + +Vue.use(Translate); + +const SUBMIT_ACTION_TEXT = { + create: __('Add'), + update: __('Save'), + delete: __('Delete'), +}; + +const SUBMIT_BUTTON_CLASS = { + create: 'btn-success', + update: 'btn-success', + delete: 'btn-remove', +}; + +export default { + components: { + GlDeprecatedButton, + GlButtonGroup, + GlFormGroup, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlModal, + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + TrackEvent: TrackEventDirective, + }, + mixins: [glFeatureFlagsMixin()], + props: { + disabled: { + type: Boolean, + required: true, + }, + errorMessage: { + type: String, + required: false, + default: '', + }, + configuredAlert: { + type: String, + required: false, + default: '', + }, + alertsToManage: { + type: Object, + required: false, + default: () => ({}), + validator: alertsValidator, + }, + relevantQueries: { + type: Array, + required: true, + validator: queriesValidator, + }, + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + operators: OPERATORS, + operator: null, + threshold: null, + prometheusMetricId: null, + selectedAlert: {}, + alertQuery: '', + }; + }, + computed: { + isValidQuery() { + // TODO: Add query validation check (most likely via http request) + return this.alertQuery.length ? true : null; + }, + currentQuery() { + return this.relevantQueries.find(query => query.metricId === this.prometheusMetricId) || {}; + }, + formDisabled() { + // We need a prometheusMetricId to determine whether we're + // creating/updating/deleting + return this.disabled || !(this.prometheusMetricId || this.isValidQuery); + }, + supportsComputedAlerts() { + return this.glFeatures.prometheusComputedAlerts; + }, + queryDropdownLabel() { + return this.currentQuery.label || s__('PrometheusAlerts|Select query'); + }, + haveValuesChanged() { + return ( + this.operator && + this.threshold === Number(this.threshold) && + (this.operator !== this.selectedAlert.operator || + this.threshold !== this.selectedAlert.threshold) + ); + }, + submitAction() { + if (isEmpty(this.selectedAlert)) return 'create'; + if (this.haveValuesChanged) return 'update'; + return 'delete'; + }, + submitActionText() { + return SUBMIT_ACTION_TEXT[this.submitAction]; + }, + submitButtonClass() { + return SUBMIT_BUTTON_CLASS[this.submitAction]; + }, + isSubmitDisabled() { + return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged); + }, + dropdownTitle() { + return this.submitAction === 'create' + ? s__('PrometheusAlerts|Add alert') + : s__('PrometheusAlerts|Edit alert'); + }, + }, + watch: { + alertsToManage() { + this.resetAlertData(); + }, + submitAction() { + this.$emit('setAction', this.submitAction); + }, + }, + methods: { + selectQuery(queryId) { + const existingAlertPath = findKey(this.alertsToManage, alert => alert.metricId === queryId); + const existingAlert = this.alertsToManage[existingAlertPath]; + + if (existingAlert) { + this.selectedAlert = existingAlert; + this.operator = existingAlert.operator; + this.threshold = existingAlert.threshold; + } else { + this.selectedAlert = {}; + this.operator = this.operators.greaterThan; + this.threshold = null; + } + + this.prometheusMetricId = queryId; + }, + handleHidden() { + this.resetAlertData(); + this.$emit('cancel'); + }, + handleSubmit(e) { + e.preventDefault(); + this.$emit(this.submitAction, { + alert: this.selectedAlert.alert_path, + operator: this.operator, + threshold: this.threshold, + prometheus_metric_id: this.prometheusMetricId, + }); + }, + resetAlertData() { + this.operator = null; + this.threshold = null; + this.prometheusMetricId = null; + this.selectedAlert = {}; + }, + getAlertFormActionTrackingOption() { + const label = `${this.submitAction}_alert`; + return { + category: document.body.dataset.page, + action: 'click_button', + label, + }; + }, + }, + alertQueryText: { + label: __('Query'), + validFeedback: __('Query is valid'), + invalidFeedback: __('Invalid query'), + descriptionTooltip: __( + 'Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula.', + ), + }, +}; +</script> + +<template> + <gl-modal + ref="alertModal" + :title="dropdownTitle" + :modal-id="modalId" + :ok-variant="submitAction === 'delete' ? 'danger' : 'success'" + :ok-disabled="formDisabled" + @ok="handleSubmit" + @hidden="handleHidden" + @shown="selectQuery(configuredAlert)" + > + <div v-if="errorMessage" class="alert-modal-message danger_message">{{ errorMessage }}</div> + <div class="alert-form"> + <gl-form-group + v-if="supportsComputedAlerts" + :label="$options.alertQueryText.label" + label-for="alert-query-input" + :valid-feedback="$options.alertQueryText.validFeedback" + :invalid-feedback="$options.alertQueryText.invalidFeedback" + :state="isValidQuery" + > + <gl-form-input id="alert-query-input" v-model.trim="alertQuery" :state="isValidQuery" /> + <template #description> + <div class="d-flex align-items-center"> + {{ __('Single or combined queries') }} + <icon + v-gl-tooltip="$options.alertQueryText.descriptionTooltip" + name="question" + class="prepend-left-4" + /> + </div> + </template> + </gl-form-group> + <gl-form-group v-else label-for="alert-query-dropdown" :label="$options.alertQueryText.label"> + <gl-dropdown + id="alert-query-dropdown" + :text="queryDropdownLabel" + toggle-class="dropdown-menu-toggle qa-alert-query-dropdown" + > + <gl-dropdown-item + v-for="query in relevantQueries" + :key="query.metricId" + data-qa-selector="alert_query_option" + @click="selectQuery(query.metricId)" + > + {{ query.label }} + </gl-dropdown-item> + </gl-dropdown> + </gl-form-group> + <gl-button-group class="mb-2" :label="s__('PrometheusAlerts|Operator')"> + <gl-deprecated-button + :class="{ active: operator === operators.greaterThan }" + :disabled="formDisabled" + type="button" + @click="operator = operators.greaterThan" + > + {{ operators.greaterThan }} + </gl-deprecated-button> + <gl-deprecated-button + :class="{ active: operator === operators.equalTo }" + :disabled="formDisabled" + type="button" + @click="operator = operators.equalTo" + > + {{ operators.equalTo }} + </gl-deprecated-button> + <gl-deprecated-button + :class="{ active: operator === operators.lessThan }" + :disabled="formDisabled" + type="button" + @click="operator = operators.lessThan" + > + {{ operators.lessThan }} + </gl-deprecated-button> + </gl-button-group> + <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold"> + <gl-form-input + id="alerts-threshold" + v-model.number="threshold" + :disabled="formDisabled" + type="number" + data-qa-selector="alert_threshold_field" + /> + </gl-form-group> + </div> + <template #modal-ok> + <gl-link + v-track-event="getAlertFormActionTrackingOption()" + class="text-reset text-decoration-none" + > + {{ submitActionText }} + </gl-link> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/annotations.js b/app/assets/javascripts/monitoring/components/charts/annotations.js index 947750b3721..418107c4126 100644 --- a/app/assets/javascripts/monitoring/components/charts/annotations.js +++ b/app/assets/javascripts/monitoring/components/charts/annotations.js @@ -1,20 +1,20 @@ -import { graphTypes, symbolSizes, colorValues } from '../../constants'; +import { graphTypes, symbolSizes, colorValues, annotationsSymbolIcon } from '../../constants'; /** * Annotations and deployments are decoration layers on * top of the actual chart data. We use a scatter plot to * display this information. Each chart has its coordinate - * system based on data and irresptive of the data, these + * system based on data and irrespective of the data, these * decorations have to be placed in specific locations. * For this reason, annotations have their own coordinate system, * * As of %12.9, only deployment icons, a type of annotations, need * to be displayed on the chart. * - * After https://gitlab.com/gitlab-org/gitlab/-/issues/211418, - * annotations and deployments will co-exist in the same - * series as they logically belong together. Annotations will be - * passed as markLine objects. + * Annotations and deployments co-exist in the same series as + * they logically belong together. Annotations are passed as + * markLines and markPoints while deployments are passed as + * data points with custom icons. */ /** @@ -45,42 +45,49 @@ export const annotationsYAxis = { * Fetched list of annotations are parsed into a * format the eCharts accepts to draw markLines * - * If Annotation is a single line, the `starting_at` property - * has a value and the `ending_at` is null. Because annotations - * only supports lines the `ending_at` value does not exist yet. - * + * If Annotation is a single line, the `startingAt` property + * has a value and the `endingAt` is null. Because annotations + * only supports lines the `endingAt` value does not exist yet. * * @param {Object} annotation object * @returns {Object} markLine object */ -export const parseAnnotations = ({ starting_at = '', color = colorValues.primaryColor }) => ({ - xAxis: starting_at, - lineStyle: { - color, - }, -}); +export const parseAnnotations = annotations => + annotations.reduce( + (acc, annotation) => { + acc.lines.push({ + xAxis: annotation.startingAt, + lineStyle: { + color: colorValues.primaryColor, + }, + }); + + acc.points.push({ + name: 'annotations', + xAxis: annotation.startingAt, + yAxis: annotationsYAxisCoords.min, + tooltipData: { + title: annotation.startingAt, + content: annotation.description, + }, + }); + + return acc; + }, + { lines: [], points: [] }, + ); /** - * This method currently generates deployments and annotations - * but are not used in the chart. The method calling - * generateAnnotationsSeries will not pass annotations until - * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 is - * implemented. - * - * This method is extracted out of the charts so that - * annotation lines can be easily supported in - * the future. - * - * In order to make hover work, hidden annotation data points - * are created along with the markLines. These data points have - * the necessart metadata that is used to display in the tooltip. + * This method generates a decorative series that has + * deployments as data points with custom icons and + * annotations as markLines and markPoints * * @param {Array} deployments deployments data * @returns {Object} annotation series object */ export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } = {}) => { // deployment data points - const deploymentsData = deployments.map(deployment => { + const data = deployments.map(deployment => { return { name: 'deployments', value: [deployment.createdAt, annotationsYAxisCoords.pos], @@ -98,31 +105,29 @@ export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } }; }); - // annotation data points - const annotationsData = annotations.map(annotation => { - return { - name: 'annotations', - value: [annotation.starting_at, annotationsYAxisCoords.pos], - // style options - symbol: 'none', - // metadata that are accessible in `formatTooltipText` method - tooltipData: { - description: annotation.description, - }, - }; - }); + const parsedAnnotations = parseAnnotations(annotations); - // annotation markLine option + // markLine option draws the annotations dotted line const markLine = { symbol: 'none', silent: true, - data: annotations.map(parseAnnotations), + data: parsedAnnotations.lines, + }; + + // markPoints are the arrows under the annotations lines + const markPoint = { + symbol: annotationsSymbolIcon, + symbolSize: '8', + symbolOffset: [0, ' 60%'], + data: parsedAnnotations.points, }; return { + name: 'annotations', type: graphTypes.annotationsData, yAxisIndex: 1, // annotationsYAxis index - data: [...deploymentsData, ...annotationsData], + data, markLine, + markPoint, }; }; diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue index 447f8845506..b3b6f9e7b55 100644 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -3,7 +3,7 @@ import { flattenDeep, isNumber } from 'lodash'; import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { roundOffFloat } from '~/lib/utils/common_utils'; import { hexToRgb } from '~/lib/utils/color_utils'; -import { areaOpacityValues, symbolSizes, colorValues } from '../../constants'; +import { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants'; import { graphDataValidatorForAnomalyValues } from '../../utils'; import MonitorTimeSeriesChart from './time_series.vue'; @@ -91,7 +91,7 @@ export default { ]); return { ...this.graphData, - type: 'line-chart', + type: panelTypes.LINE_CHART, metrics: [metricQuery], }; }, diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue index 5588d9ac060..e015ef32d8c 100644 --- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue +++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue @@ -3,12 +3,6 @@ import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-e import { chartHeight } from '../../constants'; export default { - props: { - graphTitle: { - type: String, - required: true, - }, - }, data() { return { height: chartHeight, diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js index d9f49bd81f5..09b03774580 100644 --- a/app/assets/javascripts/monitoring/components/charts/options.js +++ b/app/assets/javascripts/monitoring/components/charts/options.js @@ -6,9 +6,8 @@ const yAxisBoundaryGap = [0.1, 0.1]; * Max string length of formatted axis tick */ const maxDataAxisTickLength = 8; - // Defaults -const defaultFormat = SUPPORTED_FORMATS.number; +const defaultFormat = SUPPORTED_FORMATS.engineering; const defaultYAxisFormat = defaultFormat; const defaultYAxisPrecision = 2; @@ -26,8 +25,7 @@ const chartGridLeft = 75; * @param {Object} param - Dashboard .yml definition options */ const getDataAxisOptions = ({ format, precision, name }) => { - const formatter = getFormatter(format); - + const formatter = getFormatter(format); // default to engineeringNotation, same as gitlab-ui return { name, nameLocation: 'center', // same as gitlab-ui's default diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 9041b01088c..547f33faaa2 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -6,7 +6,7 @@ import dateFormat from 'dateformat'; import { s__, __ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; -import { chartHeight, lineTypes, lineWidths, dateFormats, tooltipTypes } from '../../constants'; +import { panelTypes, chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants'; import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options'; import { annotationsYAxis, generateAnnotationsSeries } from './annotations'; import { makeDataSeries } from '~/helpers/monitor_helper'; @@ -20,7 +20,6 @@ const events = { }; export default { - tooltipTypes, components: { GlAreaChart, GlLineChart, @@ -212,8 +211,8 @@ export default { }, glChartComponent() { const chartTypes = { - 'area-chart': GlAreaChart, - 'line-chart': GlLineChart, + [panelTypes.AREA_CHART]: GlAreaChart, + [panelTypes.LINE_CHART]: GlLineChart, }; return chartTypes[this.graphData.type] || GlAreaChart; }, @@ -262,6 +261,21 @@ export default { isTooltipOfType(tooltipType, defaultType) { return tooltipType === defaultType; }, + /** + * This method is triggered when hovered over a single markPoint. + * + * The annotations title timestamp should match the data tooltip + * title. + * + * @params {Object} params markPoint object + * @returns {Object} + */ + formatAnnotationsTooltipText(params) { + return { + title: dateFormat(params.data?.tooltipData?.title, dateFormats.default), + content: params.data?.tooltipData?.content, + }; + }, formatTooltipText(params) { this.tooltip.title = dateFormat(params.value, dateFormats.default); this.tooltip.content = []; @@ -270,15 +284,10 @@ export default { if (dataPoint.value) { const [, yVal] = dataPoint.value; this.tooltip.type = dataPoint.name; - if (this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.deployments)) { + if (this.tooltip.type === 'deployments') { const { data = {} } = dataPoint; this.tooltip.sha = data?.tooltipData?.sha; this.tooltip.commitUrl = data?.tooltipData?.commitUrl; - } else if ( - this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.annotations) - ) { - const { data } = dataPoint; - this.tooltip.content.push(data?.tooltipData?.description); } else { const { seriesName, color, dataIndex } = dataPoint; @@ -356,6 +365,7 @@ export default { :data="chartData" :option="chartOptions" :format-tooltip-text="formatTooltipText" + :format-annotations-tooltip-text="formatAnnotationsTooltipText" :thresholds="thresholds" :width="width" :height="height" @@ -364,7 +374,7 @@ export default { @created="onChartCreated" @updated="onChartUpdated" > - <template v-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.deployments)"> + <template v-if="tooltip.type === 'deployments'"> <template slot="tooltipTitle"> {{ __('Deployed') }} </template> @@ -373,16 +383,6 @@ export default { <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> </div> </template> - <template v-else-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.annotations)"> - <template slot="tooltipTitle"> - <div class="text-nowrap"> - {{ tooltip.title }} - </div> - </template> - <div slot="tooltipContent" class="d-flex align-items-center"> - {{ tooltip.content.join('\n') }} - </div> - </template> <template v-else> <template slot="tooltipTitle"> <div class="text-nowrap"> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 4586ce70ad6..85306023d7d 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -8,17 +8,17 @@ import { GlDropdownItem, GlDropdownHeader, GlDropdownDivider, - GlFormGroup, GlModal, GlLoadingIcon, GlSearchBoxByType, GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import PanelType from './panel_type_with_alerts.vue'; import { s__ } from '~/locale'; import createFlash from '~/flash'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import Icon from '~/vue_shared/components/icon.vue'; @@ -46,8 +46,8 @@ export default { GlDropdownHeader, GlDropdownDivider, GlSearchBoxByType, - GlFormGroup, GlModal, + CustomMetricsFormFields, DateTimePicker, GraphGroup, @@ -206,9 +206,6 @@ export default { }; }, computed: { - canAddMetrics() { - return this.customMetricsAvailable && this.customMetricsPath.length; - }, ...mapState('monitoringDashboard', [ 'dashboard', 'emptyState', @@ -229,7 +226,11 @@ export default { return !this.showEmptyState && this.rearrangePanelsAvailable; }, addingMetricsAvailable() { - return IS_EE && this.canAddMetrics && !this.showEmptyState; + return ( + this.customMetricsAvailable && + !this.showEmptyState && + this.firstDashboard === this.selectedDashboard + ); }, hasHeaderButtons() { return ( @@ -378,177 +379,164 @@ export default { <div v-if="showHeader" ref="prometheusGraphsHeader" - class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light" + class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" > - <div class="row"> - <gl-form-group - :label="__('Dashboard')" - label-size="sm" - label-for="monitor-dashboards-dropdown" - class="col-sm-12 col-md-6 col-lg-2" - > - <dashboards-dropdown - id="monitor-dashboards-dropdown" - data-qa-selector="dashboards_filter_dropdown" - class="mb-0 d-flex" - toggle-class="dropdown-menu-toggle" - :default-branch="defaultBranch" - :selected-dashboard="selectedDashboard" - @selectDashboard="selectDashboard($event)" - /> - </gl-form-group> + <div class="mb-2 pr-2 d-flex d-sm-block"> + <dashboards-dropdown + id="monitor-dashboards-dropdown" + data-qa-selector="dashboards_filter_dropdown" + class="flex-grow-1" + toggle-class="dropdown-menu-toggle" + :default-branch="defaultBranch" + :selected-dashboard="selectedDashboard" + @selectDashboard="selectDashboard($event)" + /> + </div> - <gl-form-group - :label="s__('Metrics|Environment')" - label-size="sm" - label-for="monitor-environments-dropdown" - class="col-sm-6 col-md-6 col-lg-2" + <div class="mb-2 pr-2 d-flex d-sm-block"> + <gl-dropdown + id="monitor-environments-dropdown" + ref="monitorEnvironmentsDropdown" + class="flex-grow-1" + data-qa-selector="environments_dropdown" + toggle-class="dropdown-menu-toggle" + menu-class="monitor-environment-dropdown-menu" + :text="currentEnvironmentName" > - <gl-dropdown - id="monitor-environments-dropdown" - ref="monitorEnvironmentsDropdown" - data-qa-selector="environments_dropdown" - class="mb-0 d-flex" - toggle-class="dropdown-menu-toggle" - menu-class="monitor-environment-dropdown-menu" - :text="currentEnvironmentName" - > - <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-header class="monitor-environment-dropdown-header text-center">{{ - __('Environment') - }}</gl-dropdown-header> - <gl-dropdown-divider /> - <gl-search-box-by-type - ref="monitorEnvironmentsDropdownSearch" - class="m-2" - @input="debouncedEnvironmentsSearch" - /> - <gl-loading-icon - v-if="environmentsLoading" - ref="monitorEnvironmentsDropdownLoading" - :inline="true" - /> - <div v-else class="flex-fill overflow-auto"> - <gl-dropdown-item - v-for="environment in filteredEnvironments" - :key="environment.id" - :active="environment.name === currentEnvironmentName" - active-class="is-active" - :href="environment.metrics_path" - >{{ environment.name }}</gl-dropdown-item - > - </div> - <div - v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" - ref="monitorEnvironmentsDropdownMsg" - class="text-secondary no-matches-message" + <div class="d-flex flex-column overflow-hidden"> + <gl-dropdown-header class="monitor-environment-dropdown-header text-center"> + {{ __('Environment') }} + </gl-dropdown-header> + <gl-dropdown-divider /> + <gl-search-box-by-type + ref="monitorEnvironmentsDropdownSearch" + class="m-2" + @input="debouncedEnvironmentsSearch" + /> + <gl-loading-icon + v-if="environmentsLoading" + ref="monitorEnvironmentsDropdownLoading" + :inline="true" + /> + <div v-else class="flex-fill overflow-auto"> + <gl-dropdown-item + v-for="environment in filteredEnvironments" + :key="environment.id" + :active="environment.name === currentEnvironmentName" + active-class="is-active" + :href="environment.metrics_path" + >{{ environment.name }}</gl-dropdown-item > - {{ __('No matching results') }} - </div> </div> - </gl-dropdown> - </gl-form-group> + <div + v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" + ref="monitorEnvironmentsDropdownMsg" + class="text-secondary no-matches-message" + > + {{ __('No matching results') }} + </div> + </div> + </gl-dropdown> + </div> - <gl-form-group - :label="s__('Metrics|Show last')" - label-size="sm" - label-for="monitor-time-window-dropdown" - class="col-sm-auto col-md-auto col-lg-auto" + <div class="mb-2 pr-2 d-flex d-sm-block"> + <date-time-picker + ref="dateTimePicker" + class="flex-grow-1 show-last-dropdown" data-qa-selector="show_last_dropdown" + :value="selectedTimeRange" + :options="timeRanges" + @input="onDateTimePickerInput" + @invalid="onDateTimePickerInvalid" + /> + </div> + + <div class="mb-2 pr-2 d-flex d-sm-block"> + <gl-deprecated-button + ref="refreshDashboardBtn" + v-gl-tooltip + class="flex-grow-1" + variant="default" + :title="s__('Metrics|Refresh dashboard')" + @click="refreshDashboard" > - <date-time-picker - ref="dateTimePicker" - :value="selectedTimeRange" - :options="timeRanges" - @input="onDateTimePickerInput" - @invalid="onDateTimePickerInvalid" - /> - </gl-form-group> + <icon name="retry" /> + </gl-deprecated-button> + </div> + + <div class="flex-grow-1"></div> - <gl-form-group class="col-sm-2 col-md-2 col-lg-1 refresh-dashboard-button"> + <div class="d-sm-flex"> + <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex"> <gl-deprecated-button - ref="refreshDashboardBtn" - v-gl-tooltip + :pressed="isRearrangingPanels" variant="default" - :title="s__('Metrics|Refresh dashboard')" - @click="refreshDashboard" + class="flex-grow-1 js-rearrange-button" + @click="toggleRearrangingPanels" > - <icon name="retry" /> + {{ __('Arrange charts') }} </gl-deprecated-button> - </gl-form-group> - - <gl-form-group - v-if="hasHeaderButtons" - label-for="prometheus-graphs-dropdown-buttons" - class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end" - > - <div id="prometheus-graphs-dropdown-buttons"> - <gl-deprecated-button - v-if="showRearrangePanelsBtn" - :pressed="isRearrangingPanels" - variant="default" - class="mr-2 mt-1 js-rearrange-button" - @click="toggleRearrangingPanels" - >{{ __('Arrange charts') }}</gl-deprecated-button - > - <gl-deprecated-button - v-if="addingMetricsAvailable" - ref="addMetricBtn" - v-gl-modal="$options.addMetric.modalId" - variant="outline-success" - data-qa-selector="add_metric_button" - class="mr-2 mt-1" - >{{ $options.addMetric.title }}</gl-deprecated-button - > - <gl-modal - v-if="addingMetricsAvailable" - ref="addMetricModal" - :modal-id="$options.addMetric.modalId" - :title="$options.addMetric.title" - > - <form ref="customMetricsForm" :action="customMetricsPath" method="post"> - <custom-metrics-form-fields - :validate-query-path="validateQueryPath" - form-operation="post" - @formValidation="setFormValidity" - /> - </form> - <div slot="modal-footer"> - <gl-deprecated-button @click="hideAddMetricModal">{{ - __('Cancel') - }}</gl-deprecated-button> - <gl-deprecated-button - ref="submitCustomMetricsFormBtn" - v-track-event="getAddMetricTrackingOptions()" - :disabled="!formIsValid" - variant="success" - @click="submitCustomMetricsForm" - >{{ __('Save changes') }}</gl-deprecated-button - > - </div> - </gl-modal> + </div> + <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + ref="addMetricBtn" + v-gl-modal="$options.addMetric.modalId" + variant="outline-success" + data-qa-selector="add_metric_button" + class="flex-grow-1" + > + {{ $options.addMetric.title }} + </gl-deprecated-button> + <gl-modal + ref="addMetricModal" + :modal-id="$options.addMetric.modalId" + :title="$options.addMetric.title" + > + <form ref="customMetricsForm" :action="customMetricsPath" method="post"> + <custom-metrics-form-fields + :validate-query-path="validateQueryPath" + form-operation="post" + @formValidation="setFormValidity" + /> + </form> + <div slot="modal-footer"> + <gl-deprecated-button @click="hideAddMetricModal"> + {{ __('Cancel') }} + </gl-deprecated-button> + <gl-deprecated-button + ref="submitCustomMetricsFormBtn" + v-track-event="getAddMetricTrackingOptions()" + :disabled="!formIsValid" + variant="success" + @click="submitCustomMetricsForm" + > + {{ __('Save changes') }} + </gl-deprecated-button> + </div> + </gl-modal> + </div> - <gl-deprecated-button - v-if="selectedDashboard.can_edit" - class="mt-1 js-edit-link" - :href="selectedDashboard.project_blob_path" - data-qa-selector="edit_dashboard_button" - >{{ __('Edit dashboard') }}</gl-deprecated-button - > + <div v-if="selectedDashboard.can_edit" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + class="flex-grow-1 js-edit-link" + :href="selectedDashboard.project_blob_path" + data-qa-selector="edit_dashboard_button" + > + {{ __('Edit dashboard') }} + </gl-deprecated-button> + </div> - <gl-deprecated-button - v-if="externalDashboardUrl.length" - class="mt-1 js-external-dashboard-link" - variant="primary" - :href="externalDashboardUrl" - target="_blank" - rel="noopener noreferrer" - > - {{ __('View full dashboard') }} - <icon name="external-link" /> - </gl-deprecated-button> - </div> - </gl-form-group> + <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block"> + <gl-deprecated-button + class="flex-grow-1 js-external-dashboard-link" + variant="primary" + :href="externalDashboardUrl" + target="_blank" + rel="noopener noreferrer" + > + {{ __('View full dashboard') }} <icon name="external-link" /> + </gl-deprecated-button> + </div> </div> </div> diff --git a/app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue b/app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue new file mode 100644 index 00000000000..be92414fd56 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/dashboard_with_alerts.vue @@ -0,0 +1,25 @@ +<script> +import CeDashboard from '~/monitoring/components/dashboard.vue'; +import AlertWidget from './alert_widget.vue'; + +export default { + components: { + AlertWidget, + }, + extends: CeDashboard, + data() { + return { + allAlerts: {}, + }; + }, + methods: { + setAlerts(alertPath, alertAttributes) { + if (alertAttributes) { + this.$set(this.allAlerts, alertPath, alertAttributes); + } else { + this.$delete(this.allAlerts, alertPath); + } + }, + }, +}; +</script> diff --git a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue index 3f8b0f76997..129de6cc2f6 100644 --- a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue +++ b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions } from 'vuex'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import PanelType from '~/monitoring/components/panel_type_with_alerts.vue'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { defaultTimeRange } from '~/vue_shared/constants'; import { timeRangeFromUrl, removeTimeRangeParams } from '../../utils'; diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 676fc0cca64..eed41b94cd3 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -4,6 +4,7 @@ import { pickBy } from 'lodash'; import invalidUrl from '~/lib/utils/invalid_url'; import { GlResizeObserverDirective, + GlIcon, GlLoadingIcon, GlDropdown, GlDropdownItem, @@ -13,7 +14,9 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { __, n__ } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; +import { panelTypes } from '../constants'; + +import MonitorEmptyChart from './charts/empty_chart.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; @@ -21,7 +24,7 @@ import MonitorHeatmapChart from './charts/heatmap.vue'; import MonitorColumnChart from './charts/column.vue'; import MonitorBarChart from './charts/bar.vue'; import MonitorStackedColumnChart from './charts/stacked_column.vue'; -import MonitorEmptyChart from './charts/empty_chart.vue'; + import TrackEventDirective from '~/vue_shared/directives/track_event'; import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; @@ -31,13 +34,13 @@ const events = { export default { components: { + MonitorEmptyChart, MonitorSingleStatChart, + MonitorHeatmapChart, MonitorColumnChart, MonitorBarChart, - MonitorHeatmapChart, MonitorStackedColumnChart, - MonitorEmptyChart, - Icon, + GlIcon, GlLoadingIcon, GlTooltip, GlDropdown, @@ -68,7 +71,7 @@ export default { groupId: { type: String, required: false, - default: 'panel-type-chart', + default: 'dashboard-panel', }, namespace: { type: String, @@ -142,7 +145,7 @@ export default { return window.URL.createObjectURL(data); }, timeChartComponent() { - if (this.isPanelType('anomaly-chart')) { + if (this.isPanelType(panelTypes.ANOMALY_CHART)) { return MonitorAnomalyChart; } return MonitorTimeSeriesChart; @@ -150,10 +153,10 @@ export default { isContextualMenuShown() { return ( this.graphDataHasResult && - !this.isPanelType('single-stat') && - !this.isPanelType('heatmap') && - !this.isPanelType('column') && - !this.isPanelType('stacked-column') + !this.isPanelType(panelTypes.SINGLE_STAT) && + !this.isPanelType(panelTypes.HEATMAP) && + !this.isPanelType(panelTypes.COLUMN) && + !this.isPanelType(panelTypes.STACKED_COLUMN) ); }, editCustomMetricLink() { @@ -198,6 +201,7 @@ export default { this.$emit(events.timeRangeZoom, { start, end }); }, }, + panelTypes, }; </script> <template> @@ -227,7 +231,7 @@ export default { </div> <div v-if="isContextualMenuShown" - class="js-graph-widgets" + ref="contextualMenu" data-qa-selector="prometheus_graph_widgets" > <div class="d-flex align-items-center"> @@ -240,7 +244,7 @@ export default { :title="__('More actions')" > <template slot="button-content"> - <icon name="ellipsis_v" class="text-secondary" /> + <gl-icon name="ellipsis_v" class="text-secondary" /> </template> <gl-dropdown-item v-if="editCustomMetricLink" @@ -288,23 +292,23 @@ export default { </div> <monitor-single-stat-chart - v-if="isPanelType('single-stat') && graphDataHasResult" + v-if="isPanelType($options.panelTypes.SINGLE_STAT) && graphDataHasResult" :graph-data="graphData" /> <monitor-heatmap-chart - v-else-if="isPanelType('heatmap') && graphDataHasResult" + v-else-if="isPanelType($options.panelTypes.HEATMAP) && graphDataHasResult" :graph-data="graphData" /> <monitor-bar-chart - v-else-if="isPanelType('bar') && graphDataHasResult" + v-else-if="isPanelType($options.panelTypes.BAR) && graphDataHasResult" :graph-data="graphData" /> <monitor-column-chart - v-else-if="isPanelType('column') && graphDataHasResult" + v-else-if="isPanelType($options.panelTypes.COLUMN) && graphDataHasResult" :graph-data="graphData" /> <monitor-stacked-column-chart - v-else-if="isPanelType('stacked-column') && graphDataHasResult" + v-else-if="isPanelType($options.panelTypes.STACKED_COLUMN) && graphDataHasResult" :graph-data="graphData" /> <component @@ -319,6 +323,6 @@ export default { :group-id="groupId" @datazoom="onDatazoom" /> - <monitor-empty-chart v-else :graph-title="title" v-bind="$attrs" v-on="$listeners" /> + <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue b/app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue new file mode 100644 index 00000000000..ca81242af2e --- /dev/null +++ b/app/assets/javascripts/monitoring/components/panel_type_with_alerts.vue @@ -0,0 +1,55 @@ +<script> +import { mapGetters } from 'vuex'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; +import CePanelType from '~/monitoring/components/panel_type.vue'; +import AlertWidget from './alert_widget.vue'; + +export default { + components: { + AlertWidget, + CustomMetricsFormFields, + }, + extends: CePanelType, + props: { + alertsEndpoint: { + type: String, + required: false, + default: null, + }, + prometheusAlertsAvailable: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + allAlerts: {}, + }; + }, + computed: { + ...mapGetters('monitoringDashboard', ['metricsSavedToDb']), + hasMetricsInDb() { + const { metrics = [] } = this.graphData; + return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId)); + }, + alertWidgetAvailable() { + return ( + this.prometheusAlertsAvailable && + this.alertsEndpoint && + this.graphData && + this.hasMetricsInDb + ); + }, + }, + methods: { + setAlerts(alertPath, alertAttributes) { + if (alertAttributes) { + this.$set(this.allAlerts, alertPath, alertAttributes); + } else { + this.$delete(this.allAlerts, alertPath); + } + }, + }, +}; +</script> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 8d821c27099..f2f0a0eac7b 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -48,6 +48,55 @@ export const metricStates = { UNKNOWN_ERROR: 'UNKNOWN_ERROR', }; +/** + * Supported panel types in dashboards, values of `panel.type`. + * + * Values should not be changed as they correspond to + * values in users the `.yml` dashboard definition. + */ +export const panelTypes = { + /** + * Area Chart + * + * Time Series chart with an area + */ + AREA_CHART: 'area-chart', + /** + * Line Chart + * + * Time Series chart with a line + */ + LINE_CHART: 'line-chart', + /** + * Anomaly Chart + * + * Time Series chart with 3 metrics + */ + ANOMALY_CHART: 'anomaly-chart', + /** + * Single Stat + * + * Single data point visualization + */ + SINGLE_STAT: 'single-stat', + /** + * Heatmap + */ + HEATMAP: 'heatmap', + /** + * Bar chart + */ + BAR: 'bar', + /** + * Column chart + */ + COLUMN: 'column', + /** + * Stacked column chart + */ + STACKED_COLUMN: 'stacked-column', +}; + export const sidebarAnimationDuration = 300; // milliseconds. export const chartHeight = 300; @@ -120,10 +169,32 @@ export const NOT_IN_DB_PREFIX = 'NO_DB'; export const ENVIRONMENT_AVAILABLE_STATE = 'available'; /** - * Time series charts have different types of - * tooltip based on the hovered data point. + * As of %12.10, the svg icon library does not have an annotation + * arrow icon yet. In order to deliver annotations feature, the icon + * is hard coded until the icon is added. The below issue is + * to track the icon. + * + * https://gitlab.com/gitlab-org/gitlab-svgs/-/issues/118 + * + * Once the icon is merged this can be removed. + * https://gitlab.com/gitlab-org/gitlab/-/issues/214540 + */ +export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; + +/** + * As of %12.10, dashboard path is required to create annotation. + * The FE gets the dashboard name from the URL params. It is not + * ideal to store the path this way but there is no other way to + * get this path unless annotations fetch is delayed. This could + * potentially be removed and have the backend send this to the FE. + * + * This technical debt is being tracked here + * https://gitlab.com/gitlab-org/gitlab/-/issues/214671 */ -export const tooltipTypes = { - deployments: 'deployments', - annotations: 'annotations', +export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; + +export const OPERATORS = { + greaterThan: '>', + equalTo: '==', + lessThan: '<', }; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index d296f5b7a66..99af8ccaf05 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; -import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; +import Dashboard from '~/monitoring/components/dashboard_with_alerts.vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterValues } from '~/lib/utils/url_utility'; import store from './stores'; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js new file mode 100644 index 00000000000..afe5ee0938d --- /dev/null +++ b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js @@ -0,0 +1,13 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; +import initCeBundle from '~/monitoring/monitoring_bundle'; + +export default () => { + const el = document.getElementById('prometheus-graphs'); + + if (el && el.dataset) { + initCeBundle({ + customMetricsAvailable: parseBoolean(el.dataset.customMetricsAvailable), + prometheusAlertsAvailable: parseBoolean(el.dataset.prometheusAlertsAvailable), + }); + } +}; diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql index 2fd698eadf9..27b49860b8a 100644 --- a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql @@ -1,12 +1,25 @@ -query getAnnotations($projectPath: ID!) { - environment(name: $environmentName) { - metricDashboard(id: $dashboardId) { - annotations: nodes { +query getAnnotations( + $projectPath: ID! + $environmentName: String + $dashboardPath: String! + $startingFrom: Time! +) { + project(fullPath: $projectPath) { + environments(name: $environmentName) { + nodes { id - description - starting_at - ending_at - panelId + name + metricsDashboard(path: $dashboardPath) { + annotations(from: $startingFrom) { + nodes { + id + description + startingAt + endingAt + panelId + } + } + } } } } diff --git a/app/assets/javascripts/monitoring/services/alerts_service.js b/app/assets/javascripts/monitoring/services/alerts_service.js new file mode 100644 index 00000000000..4b7337972fe --- /dev/null +++ b/app/assets/javascripts/monitoring/services/alerts_service.js @@ -0,0 +1,32 @@ +import axios from '~/lib/utils/axios_utils'; + +export default class AlertsService { + constructor({ alertsEndpoint }) { + this.alertsEndpoint = alertsEndpoint; + } + + getAlerts() { + return axios.get(this.alertsEndpoint).then(resp => resp.data); + } + + createAlert({ prometheus_metric_id, operator, threshold }) { + return axios + .post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold }) + .then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + readAlert(alertPath) { + return axios.get(alertPath).then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + updateAlert(alertPath, { operator, threshold }) { + return axios.put(alertPath, { operator, threshold }).then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + deleteAlert(alertPath) { + return axios.delete(alertPath).then(resp => resp.data); + } +} diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 3201f1d4584..f04f775761c 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -3,7 +3,12 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import { gqClient, parseEnvironmentsResponse, removeLeadingSlash } from './utils'; +import { + gqClient, + parseEnvironmentsResponse, + parseAnnotationsResponse, + removeLeadingSlash, +} from './utils'; import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; @@ -15,7 +20,11 @@ import { } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; -import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE } from '../constants'; +import { + PROMETHEUS_TIMEOUT, + ENVIRONMENT_AVAILABLE_STATE, + DEFAULT_DASHBOARD_PATH, +} from '../constants'; function prometheusMetricQueryParams(timeRange) { const { start, end } = convertToFixedRange(timeRange); @@ -283,16 +292,21 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { }; export const fetchAnnotations = ({ state, dispatch }) => { + const { start } = convertToFixedRange(state.timeRange); + const dashboardPath = + state.currentDashboard === '' ? DEFAULT_DASHBOARD_PATH : state.currentDashboard; return gqClient .mutate({ mutation: getAnnotations, variables: { projectPath: removeLeadingSlash(state.projectPath), - dashboardId: state.currentDashboard, environmentName: state.currentEnvironmentName, + dashboardPath, + startingFrom: start, }, }) - .then(resp => resp.data?.project?.environment?.metricDashboard?.annotations) + .then(resp => resp.data?.project?.environments?.nodes?.[0].metricsDashboard?.annotations.nodes) + .then(parseAnnotationsResponse) .then(annotations => { if (!annotations) { createFlash(s__('Metrics|There was an error fetching annotations. Please try again.')); diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index a212e9be703..9f06d18c46f 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -58,6 +58,31 @@ export const parseEnvironmentsResponse = (response = [], projectPath) => }); /** + * Annotation API returns time in UTC. This method + * converts time to local time. + * + * startingAt always exists but endingAt does not. + * If endingAt does not exist, a threshold line is + * drawn. + * + * If endingAt exists, a threshold range is drawn. + * But this is not supported as of %12.10 + * + * @param {Array} response annotations response + * @returns {Array} parsed responses + */ +export const parseAnnotationsResponse = response => { + if (!response) { + return []; + } + return response.map(annotation => ({ + ...annotation, + startingAt: new Date(annotation.startingAt), + endingAt: annotation.endingAt ? new Date(annotation.endingAt) : null, + })); +}; + +/** * Maps metrics to its view model * * This function difers from other in that is maps all @@ -95,15 +120,19 @@ const mapXAxisToViewModel = ({ name = '' }) => ({ name }); /** * Maps Y-axis view model * - * Defaults to a 2 digit precision and `number` format. It only allows + * Defaults to a 2 digit precision and `engineering` format. It only allows * formats in the SUPPORTED_FORMATS array. * * @param {Object} axis */ -const mapYAxisToViewModel = ({ name = '', format = SUPPORTED_FORMATS.number, precision = 2 }) => { +const mapYAxisToViewModel = ({ + name = '', + format = SUPPORTED_FORMATS.engineering, + precision = 2, +}) => { return { name, - format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.number, + format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.engineering, precision, }; }; diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js new file mode 100644 index 00000000000..cd426f1a221 --- /dev/null +++ b/app/assets/javascripts/monitoring/validators.js @@ -0,0 +1,44 @@ +// Prop validator for alert information, expecting an object like the example below. +// +// { +// '/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37': { +// alert_path: "/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37", +// metricId: '1', +// operator: ">", +// query: "rate(http_requests_total[5m])[30m:1m]", +// threshold: 0.002, +// title: "Core Usage (Total)", +// } +// } +export function alertsValidator(value) { + return Object.keys(value).every(key => { + const alert = value[key]; + return ( + alert.alert_path && + key === alert.alert_path && + alert.metricId && + typeof alert.metricId === 'string' && + alert.operator && + typeof alert.threshold === 'number' + ); + }); +} + +// Prop validator for query information, expecting an array like the example below. +// +// [ +// { +// metricId: '16', +// label: 'Total Cores' +// }, +// { +// metricId: '17', +// label: 'Sub-total Cores' +// } +// ] +export function queriesValidator(value) { + return value.every( + query => + query.metricId && typeof query.metricId === 'string' && typeof query.label === 'string', + ); +} diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 9a809b71a58..a070cf8866a 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -3,6 +3,7 @@ import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import { isEmpty } from 'lodash'; import Autosize from 'autosize'; +import { GlAlert, GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; @@ -34,6 +35,10 @@ export default { userAvatarLink, loadingButton, TimelineEntryItem, + GlAlert, + GlIntersperse, + GlLink, + GlSprintf, }, mixins: [issuableStateMixin], props: { @@ -57,8 +62,9 @@ export default { 'getNoteableData', 'getNotesData', 'openState', + 'getBlockedByIssues', ]), - ...mapState(['isToggleStateButtonLoading']), + ...mapState(['isToggleStateButtonLoading', 'isToggleBlockedIssueWarning']), noteableDisplayName() { return splitCamelCase(this.noteableType).toLowerCase(); }, @@ -159,6 +165,7 @@ export default { 'reopenIssue', 'toggleIssueLocalState', 'toggleStateButtonLoading', + 'toggleBlockedIssueWarning', ]), setIsSubmitButtonDisabled(note, isSubmitting) { if (!isEmpty(note) && !isSubmitting) { @@ -220,22 +227,17 @@ export default { this.isSubmitting = false; }, toggleIssueState() { + if ( + this.noteableType.toLowerCase() === constants.ISSUE_NOTEABLE_TYPE && + this.isOpen && + this.getBlockedByIssues && + this.getBlockedByIssues.length > 0 + ) { + this.toggleBlockedIssueWarning(true); + return; + } if (this.isOpen) { - this.closeIssue() - .then(() => { - this.enableButton(); - refreshUserMergeRequestCounts(); - }) - .catch(() => { - this.enableButton(); - this.toggleStateButtonLoading(false); - Flash( - sprintf( - __('Something went wrong while closing the %{issuable}. Please try again later'), - { issuable: this.noteableDisplayName }, - ), - ); - }); + this.forceCloseIssue(); } else { this.reopenIssue() .then(() => { @@ -258,6 +260,23 @@ export default { }); } }, + forceCloseIssue() { + this.closeIssue() + .then(() => { + this.enableButton(); + refreshUserMergeRequestCounts(); + }) + .catch(() => { + this.enableButton(); + this.toggleStateButtonLoading(false); + Flash( + sprintf( + __('Something went wrong while closing the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, + ), + ); + }); + }, discard(shouldClear = true) { // `blur` is needed to clear slash commands autocomplete cache if event fired. // `focus` is needed to remain cursor in the textarea. @@ -361,6 +380,36 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input" > </textarea> </markdown-field> + <gl-alert + v-if="isToggleBlockedIssueWarning" + class="prepend-top-16" + :title="__('Are you sure you want to close this blocked issue?')" + :primary-button-text="__('Yes, close issue')" + :secondary-button-text="__('Cancel')" + variant="warning" + :dismissible="false" + @primaryAction="forceCloseIssue" + @secondaryAction="toggleBlockedIssueWarning(false) && enableButton()" + > + <p> + <gl-sprintf + :message=" + __('This issue is currently blocked by the following issues: %{issues}.') + " + > + <template #issues> + <gl-intersperse> + <gl-link + v-for="blockingIssue in getBlockedByIssues" + :key="blockingIssue.web_url" + :href="blockingIssue.web_url" + >#{{ blockingIssue.iid }}</gl-link + > + </gl-intersperse> + </template> + </gl-sprintf> + </p> + </gl-alert> <div class="note-form-actions"> <div class="float-left btn-group @@ -427,7 +476,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" </div> <loading-button - v-if="canToggleIssueState" + v-if="canToggleIssueState && !isToggleBlockedIssueWarning" :loading="isToggleStateButtonLoading" :container-class="[ actionButtonClassNames, diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index df62e379017..5181b5f26ee 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,17 +1,12 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import tooltip from '~/vue_shared/directives/tooltip'; -import Icon from '~/vue_shared/components/icon.vue'; +import AwardsList from '~/vue_shared/components/awards_list.vue'; import Flash from '../../flash'; -import { glEmojiTag } from '../../emoji'; -import { __, sprintf } from '~/locale'; +import { __ } from '~/locale'; export default { components: { - Icon, - }, - directives: { - tooltip, + AwardsList, }, props: { awards: { @@ -37,130 +32,20 @@ export default { }, computed: { ...mapGetters(['getUserData']), - // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. - // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] - // This method will group emojis by their name as an Object. See below. - // { - // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], - // bar: [ { name: bar, user: user1 } ] - // } - // We need to do this otherwise we will render the same emoji over and over again. - groupedAwards() { - const awards = this.awards.reduce((acc, award) => { - if (Object.prototype.hasOwnProperty.call(acc, award.name)) { - acc[award.name].push(award); - } else { - Object.assign(acc, { [award.name]: [award] }); - } - - return acc; - }, {}); - - const orderedAwards = {}; - const { thumbsdown, thumbsup } = awards; - // Always show thumbsup and thumbsdown first - if (thumbsup) { - orderedAwards.thumbsup = thumbsup; - delete awards.thumbsup; - } - if (thumbsdown) { - orderedAwards.thumbsdown = thumbsdown; - delete awards.thumbsdown; - } - - return Object.assign({}, orderedAwards, awards); - }, isAuthoredByMe() { return this.noteAuthorId === this.getUserData.id; }, + addButtonClass() { + return this.isAuthoredByMe ? 'js-user-authored' : ''; + }, }, methods: { ...mapActions(['toggleAwardRequest']), - getAwardHTML(name) { - return glEmojiTag(name); - }, - getAwardClassBindings(awardList) { - return { - active: this.hasReactionByCurrentUser(awardList), - disabled: !this.canInteractWithEmoji(), - }; - }, - canInteractWithEmoji() { - return this.getUserData.id; - }, - hasReactionByCurrentUser(awardList) { - return awardList.filter(award => award.user.id === this.getUserData.id).length; - }, - awardTitle(awardsList) { - const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); - const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; - let awardList = awardsList; - - // Filter myself from list if I am awarded. - if (hasReactionByCurrentUser) { - awardList = awardList.filter(award => award.user.id !== this.getUserData.id); - } - - // Get only 9-10 usernames to show in tooltip text. - const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); - - // Get the remaining list to use in `and x more` text. - const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); - - // Add myself to the beginning of the list so title will start with You. - if (hasReactionByCurrentUser) { - namesToShow.unshift(__('You')); - } - - let title = ''; - - // We have 10+ awarded user, join them with comma and add `and x more`. - if (remainingAwardList.length) { - title = sprintf( - __(`%{listToShow}, and %{awardsListLength} more.`), - { - listToShow: namesToShow.join(', '), - awardsListLength: remainingAwardList.length, - }, - false, - ); - } else if (namesToShow.length > 1) { - // Join all names with comma but not the last one, it will be added with and text. - title = namesToShow.slice(0, namesToShow.length - 1).join(', '); - // If we have more than 2 users we need an extra comma before and text. - title += namesToShow.length > 2 ? ',' : ''; - title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text - } else { - // We have only 2 users so join them with and. - title = namesToShow.join(__(' and ')); - } - - return title; - }, handleAward(awardName) { - if (!this.canAwardEmoji) { - return; - } - - let parsedName; - - // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string - switch (awardName) { - case '100': - parsedName = 100; - break; - case '1234': - parsedName = 1234; - break; - default: - parsedName = awardName; - break; - } - const data = { endpoint: this.toggleAwardPath, noteId: this.noteId, - awardName: parsedName, + awardName, }; this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.'))); @@ -171,46 +56,12 @@ export default { <template> <div class="note-awards"> - <div class="awards js-awards-block"> - <button - v-for="(awardList, awardName, index) in groupedAwards" - :key="index" - v-tooltip - :class="getAwardClassBindings(awardList)" - :title="awardTitle(awardList)" - data-boundary="viewport" - class="btn award-control" - type="button" - @click="handleAward(awardName)" - > - <span v-html="getAwardHTML(awardName)"></span> - <span class="award-control-text js-counter">{{ awardList.length }}</span> - </button> - <div v-if="canAwardEmoji" class="award-menu-holder"> - <button - v-tooltip - :class="{ 'js-user-authored': isAuthoredByMe }" - class="award-control btn js-add-award" - title="Add reaction" - :aria-label="__('Add reaction')" - data-boundary="viewport" - type="button" - > - <span class="award-control-icon award-control-icon-neutral"> - <icon name="slight-smile" /> - </span> - <span class="award-control-icon award-control-icon-positive"> - <icon name="smiley" /> - </span> - <span class="award-control-icon award-control-icon-super-positive"> - <icon name="smiley" /> - </span> - <i - aria-hidden="true" - class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" - ></i> - </button> - </div> - </div> + <awards-list + :awards="awards" + :can-award-emoji="canAwardEmoji" + :current-user-id="getUserData.id" + :add-button-class="addButtonClass" + @award="handleAward($event)" + /> </div> </template> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index f82b3554cac..74a0b69bc54 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -45,6 +45,13 @@ export default { default: true, }, }, + data() { + return { + isUsernameLinkHovered: false, + emojiTitle: '', + authorStatusHasTooltip: false, + }; + }, computed: { toggleChevronClass() { return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; @@ -58,6 +65,28 @@ export default { showGitlabTeamMemberBadge() { return this.author?.is_gitlab_employee; }, + authorLinkClasses() { + return { + hover: this.isUsernameLinkHovered, + 'text-underline': this.isUsernameLinkHovered, + 'author-name-link': true, + 'js-user-link': true, + }; + }, + authorStatus() { + return this.author.status_tooltip_html; + }, + emojiElement() { + return this.$refs?.authorStatus?.querySelector('gl-emoji'); + }, + }, + mounted() { + this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : ''; + + const authorStatusTitle = this.$refs?.authorStatus + ?.querySelector('.user-status-emoji') + ?.getAttribute('title'); + this.authorStatusHasTooltip = authorStatusTitle && authorStatusTitle !== ''; }, methods: { ...mapActions(['setTargetNoteHash']), @@ -69,6 +98,20 @@ export default { this.setTargetNoteHash(this.noteTimestampLink); } }, + removeEmojiTitle() { + this.emojiElement.removeAttribute('title'); + }, + addEmojiTitle() { + this.emojiElement.setAttribute('title', this.emojiTitle); + }, + handleUsernameMouseEnter() { + this.$refs.authorNameLink.dispatchEvent(new Event('mouseenter')); + this.isUsernameLinkHovered = true; + }, + handleUsernameMouseLeave() { + this.$refs.authorNameLink.dispatchEvent(new Event('mouseleave')); + this.isUsernameLinkHovered = false; + }, }, }; </script> @@ -87,18 +130,34 @@ export default { </div> <template v-if="hasAuthor"> <a - v-once + ref="authorNameLink" :href="author.path" - class="js-user-link" + :class="authorLinkClasses" :data-user-id="author.id" :data-username="author.username" > <slot name="note-header-info"></slot> <span class="note-header-author-name bold">{{ author.name }}</span> - <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> - <span class="note-headline-light">@{{ author.username }}</span> </a> - <gitlab-team-member-badge v-if="showGitlabTeamMemberBadge" /> + <span + v-if="authorStatus" + ref="authorStatus" + v-on=" + authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {} + " + v-html="authorStatus" + ></span> + <span class="text-nowrap author-username"> + <a + ref="authorUsernameLink" + class="author-username-link" + :href="author.path" + @mouseenter="handleUsernameMouseEnter" + @mouseleave="handleUsernameMouseLeave" + ><span class="note-headline-light">@{{ author.username }}</span> + </a> + <gitlab-team-member-badge v-if="showGitlabTeamMemberBadge" /> + </span> </template> <span v-else>{{ __('A deleted user') }}</span> <span class="note-headline-light note-headline-meta"> diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 1b80b59621a..a358515c2ec 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -185,12 +185,27 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, }); }; +export const toggleBlockedIssueWarning = ({ commit }, value) => { + commit(types.TOGGLE_BLOCKED_ISSUE_WARNING, value); + // Hides Close issue button at the top of issue page + const closeDropdown = document.querySelector('.js-issuable-close-dropdown'); + if (closeDropdown) { + closeDropdown.classList.toggle('d-none'); + } else { + const closeButton = document.querySelector( + '.detail-page-header-actions .btn-close.btn-grouped', + ); + closeButton.classList.toggle('d-md-block'); + } +}; + export const closeIssue = ({ commit, dispatch, state }) => { dispatch('toggleStateButtonLoading', true); return axios.put(state.notesData.closePath).then(({ data }) => { commit(types.CLOSE_ISSUE); dispatch('emitStateChangedEvent', data); dispatch('toggleStateButtonLoading', false); + dispatch('toggleBlockedIssueWarning', false); }); }; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index eb877083bca..85997b44bcc 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -35,6 +35,8 @@ export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; +export const getBlockedByIssues = state => state.noteableData.blocked_by_issues; + export const userCanReply = state => Boolean(state.noteableData.current_user.can_create_note); export const openState = state => state.noteableData.state; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 81844ad6e98..2e5e7f47099 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -14,6 +14,7 @@ export default () => ({ // View layer isToggleStateButtonLoading: false, + isToggleBlockedIssueWarning: false, isNotesFetched: false, isLoading: true, isLoadingDescriptionVersion: false, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 5b7225bb3d2..2f7b2788d8a 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -33,6 +33,7 @@ export const SET_DISCUSSIONS_SORT = 'SET_DISCUSSIONS_SORT'; export const CLOSE_ISSUE = 'CLOSE_ISSUE'; export const REOPEN_ISSUE = 'REOPEN_ISSUE'; export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; +export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING'; // Description version export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index dab09d1d05c..f06874991f0 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -249,6 +249,10 @@ export default { Object.assign(state, { isToggleStateButtonLoading: value }); }, + [types.TOGGLE_BLOCKED_ISSUE_WARNING](state, value) { + Object.assign(state, { isToggleBlockedIssueWarning: value }); + }, + [types.SET_NOTES_FETCHED_STATE](state, value) { Object.assign(state, { isNotesFetched: value }); }, diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index 1ef18b356f2..479c82265f2 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,13 +1,10 @@ import initSettingsPanels from '~/settings_panels'; import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import initVariableList from '~/ci_variable_list'; -import DueDateSelectors from '~/due_date_select'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels initSettingsPanels(); - // eslint-disable-next-line no-new - new DueDateSelectors(); if (gon.features.newVariablesUi) { initVariableList(); diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js new file mode 100644 index 00000000000..f4b26ba81fe --- /dev/null +++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js @@ -0,0 +1,9 @@ +import initSettingsPanels from '~/settings_panels'; +import DueDateSelectors from '~/due_date_select'; + +document.addEventListener('DOMContentLoaded', () => { + // Initialize expandable settings panels + initSettingsPanels(); + + new DueDateSelectors(); // eslint-disable-line no-new +}); diff --git a/app/assets/javascripts/pages/projects/alert_management/index/index.js b/app/assets/javascripts/pages/projects/alert_management/index/index.js new file mode 100644 index 00000000000..1e98bcfd2eb --- /dev/null +++ b/app/assets/javascripts/pages/projects/alert_management/index/index.js @@ -0,0 +1,5 @@ +import AlertManagementList from '~/alert_management/list'; + +document.addEventListener('DOMContentLoaded', () => { + AlertManagementList(); +}); diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js index 0d69a689316..31ec4e29ad2 100644 --- a/app/assets/javascripts/pages/projects/environments/metrics/index.js +++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js @@ -1,3 +1,3 @@ -import monitoringBundle from 'ee_else_ce/monitoring/monitoring_bundle'; +import monitoringBundle from '~/monitoring/monitoring_bundle_with_alerts'; document.addEventListener('DOMContentLoaded', monitoringBundle); 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 a3743ded601..6efddec1172 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 @@ -3,6 +3,7 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; import { s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import projectSettingRow from './project_setting_row.vue'; @@ -24,7 +25,7 @@ export default { GlSprintf, GlLink, }, - mixins: [settingsMixin], + mixins: [settingsMixin, glFeatureFlagsMixin()], props: { currentSettings: { @@ -116,6 +117,8 @@ export default { const defaults = { visibilityOptions, visibilityLevel: visibilityOptions.PUBLIC, + // TODO: Change all of these to use the visibilityOptions constants + // https://gitlab.com/gitlab-org/gitlab/-/issues/214667 issuesAccessLevel: 20, repositoryAccessLevel: 20, forkingAccessLevel: 20, @@ -124,11 +127,14 @@ export default { wikiAccessLevel: 20, snippetsAccessLevel: 20, pagesAccessLevel: 20, + metricsAccessLevel: visibilityOptions.PRIVATE, containerRegistryEnabled: true, lfsEnabled: true, requestAccessEnabled: true, highlightChangesClass: false, emailsDisabled: false, + featureAccessLevelEveryone, + featureAccessLevelMembers, }; return { ...defaults, ...this.currentSettings }; @@ -189,6 +195,10 @@ export default { 'ProjectSettings|View and edit files in this project. Non-project members will only have read access', ); }, + + metricsDashboardVisibilitySwitchingAvailable() { + return this.glFeatures.metricsDashboardVisibilitySwitchingAvailable; + }, }, watch: { @@ -462,6 +472,38 @@ export default { name="project[project_feature_attributes][pages_access_level]" /> </project-setting-row> + <project-setting-row + v-if="metricsDashboardVisibilitySwitchingAvailable" + ref="metrics-visibility-settings" + :label="__('Metrics Dashboard')" + :help-text=" + s__( + 'ProjectSettings|With Metrics Dashboard you can visualize this project performance metrics', + ) + " + > + <div class="project-feature-controls"> + <div class="select-wrapper"> + <select + v-model="metricsAccessLevel" + name="project[project_feature_attributes][metrics_dashboard_access_level]" + class="form-control select-control" + > + <option + :value="visibilityOptions.PRIVATE" + :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" + >{{ featureAccessLevelMembers[1] }}</option + > + <option + :value="visibilityOptions.PUBLIC" + :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" + >{{ featureAccessLevelEveryone[1] }}</option + > + </select> + <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> + </div> + </div> + </project-setting-row> </div> <project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3"> <label class="js-emails-disabled"> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 388b300b39d..06ab45adf80 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -21,7 +21,8 @@ export default { return this.selectedSuite.total_count > 0; }, showTests() { - return this.testReports.total_count > 0; + const { test_suites: testSuites = [] } = this.testReports; + return testSuites.length > 0; }, }, methods: { diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 6effd6e949d..4dfb67dd8e8 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -1,14 +1,19 @@ <script> import { mapGetters } from 'vuex'; import { s__ } from '~/locale'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import store from '~/pipelines/stores/test_reports'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { name: 'TestsSummaryTable', components: { + GlIcon, SmartVirtualList, }, + directives: { + GlTooltip: GlTooltipDirective, + }, store, props: { heading: { @@ -75,7 +80,10 @@ export default { v-for="(testSuite, index) in getTestSuites" :key="index" role="row" - class="gl-responsive-table-row gl-responsive-table-row-clickable test-reports-summary-row rounded cursor-pointer js-suite-row" + class="gl-responsive-table-row test-reports-summary-row rounded js-suite-row" + :class="{ + 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error, + }" @click="tableRowClick(testSuite)" > <div class="table-section section-25"> @@ -84,6 +92,14 @@ export default { </div> <div class="table-mobile-content underline cgray pl-3"> {{ testSuite.name }} + <gl-icon + v-if="testSuite.suite_error" + ref="suiteErrorIcon" + v-gl-tooltip + name="error" + :title="testSuite.suite_error" + class="vertical-align-middle" + /> </div> </div> diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index daeae071d6a..a3a53c2f975 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; @@ -26,6 +27,9 @@ export default { }, }) .then(({ data }) => dispatch('receiveAuthorsSuccess', data)) - .catch(() => dispatch('receiveAuthorsError')); + .catch(error => { + Sentry.captureException(error); + dispatch('receiveAuthorsError'); + }); }, }; diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index e5898c3b047..2d321ead33e 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -53,6 +53,10 @@ export default { text: s__('ProjectTemplates|Pages/Hexo'), icon: '.template-option .icon-hexo', }, + sse_middleman: { + text: s__('ProjectTemplates|Static Site Editor/Middleman'), + icon: '.template-option .icon-sse_middleman', + }, nfhugo: { text: s__('ProjectTemplates|Netlify/Hugo'), icon: '.template-option .icon-nfhugo', diff --git a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue index 6acf366e531..88a0710574f 100644 --- a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue +++ b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue @@ -53,7 +53,6 @@ export default { :primary-button-text="alertConfiguration.primaryButton" :primary-button-link="config.settingsPath" :title="alertConfiguration.title" - class="my-2" > <gl-sprintf :message="alertConfiguration.message"> <template #days> diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js index 586231d19c7..d4b9d25b212 100644 --- a/app/assets/javascripts/registry/explorer/constants.js +++ b/app/assets/javascripts/registry/explorer/constants.js @@ -1,16 +1,44 @@ import { s__ } from '~/locale'; +// List page + +export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry'); +export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error'); +export const CONNECTION_ERROR_MESSAGE = s__( + `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, +); +export const LIST_INTRO_TEXT = s__( + `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, +); + +export const LIST_DELETE_BUTTON_DISABLED = s__( + 'ContainerRegistry|Missing or insufficient permission, delete button disabled', +); +export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository'); +export const REMOVE_REPOSITORY_MODAL_TEXT = s__( + 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', +); +export const ROW_SCHEDULED_FOR_DELETION = s__( + `ContainerRegistry|This image repository is scheduled for deletion`, +); export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the packages list.', + 'ContainerRegistry|Something went wrong while fetching the repository list.', ); export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while fetching the tags list.', ); - export const DELETE_IMAGE_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while deleting the image.', + 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.', ); -export const DELETE_IMAGE_SUCCESS_MESSAGE = s__('ContainerRegistry|Image deleted successfully'); +export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__( + `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`, +); +export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|%{title} was successfully scheduled for deletion', +); + +// Image details page + export const DELETE_TAG_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while deleting the tag.', ); @@ -37,6 +65,8 @@ export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID'); export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size'); export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated'); +// Expiration policies + export const EXPIRATION_POLICY_ALERT_TITLE = s__( 'ContainerRegistry|Retention policy has been Enabled', ); @@ -48,6 +78,8 @@ export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__( 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}', ); +// Quick Start + export const QUICK_START = s__('ContainerRegistry|Quick Start'); export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); @@ -55,3 +87,8 @@ export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command'); export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image'); export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command'); + +// Image state + +export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; +export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 7204cbd90eb..8923c305b2d 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -9,16 +9,28 @@ import { GlModal, GlSprintf, GlLink, + GlAlert, GlSkeletonLoader, } from '@gitlab/ui'; import Tracking from '~/tracking'; -import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ProjectEmptyState from '../components/project_empty_state.vue'; import GroupEmptyState from '../components/group_empty_state.vue'; import ProjectPolicyAlert from '../components/project_policy_alert.vue'; import QuickstartDropdown from '../components/quickstart_dropdown.vue'; -import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE } from '../constants'; +import { + DELETE_IMAGE_SUCCESS_MESSAGE, + DELETE_IMAGE_ERROR_MESSAGE, + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + CONTAINER_REGISTRY_TITLE, + CONNECTION_ERROR_TITLE, + CONNECTION_ERROR_MESSAGE, + LIST_INTRO_TEXT, + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + REMOVE_REPOSITORY_MODAL_TEXT, + ROW_SCHEDULED_FOR_DELETION, +} from '../constants'; export default { name: 'RegistryListApp', @@ -35,6 +47,7 @@ export default { GlModal, GlSprintf, GlLink, + GlAlert, GlSkeletonLoader, }, directives: { @@ -47,25 +60,20 @@ export default { height: 40, }, i18n: { - containerRegistryTitle: s__('ContainerRegistry|Container Registry'), - connectionErrorTitle: s__('ContainerRegistry|Docker connection error'), - connectionErrorMessage: s__( - `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, - ), - introText: s__( - `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, - ), - deleteButtonDisabled: s__( - 'ContainerRegistry|Missing or insufficient permission, delete button disabled', - ), - removeRepositoryLabel: s__('ContainerRegistry|Remove repository'), - removeRepositoryModalText: s__( - 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', - ), + containerRegistryTitle: CONTAINER_REGISTRY_TITLE, + connectionErrorTitle: CONNECTION_ERROR_TITLE, + connectionErrorMessage: CONNECTION_ERROR_MESSAGE, + introText: LIST_INTRO_TEXT, + deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED, + removeRepositoryLabel: REMOVE_REPOSITORY_LABEL, + removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT, + rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION, + asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE, }, data() { return { itemToDelete: {}, + deleteAlertType: null, }; }, computed: { @@ -86,43 +94,61 @@ export default { showQuickStartDropdown() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, + showDeleteAlert() { + return this.deleteAlertType && this.itemToDelete?.path; + }, + deleteImageAlertMessage() { + return this.deleteAlertType === 'success' + ? DELETE_IMAGE_SUCCESS_MESSAGE + : DELETE_IMAGE_ERROR_MESSAGE; + }, }, methods: { ...mapActions(['requestImagesList', 'requestDeleteImage']), deleteImage(item) { - // This event is already tracked in the system and so the name must be kept to aggregate the data this.track('click_button'); this.itemToDelete = item; this.$refs.deleteModal.show(); }, handleDeleteImage() { this.track('confirm_delete'); - return this.requestDeleteImage(this.itemToDelete.destroy_path) - .then(() => - this.$toast.show(DELETE_IMAGE_SUCCESS_MESSAGE, { - type: 'success', - }), - ) - .catch(() => - this.$toast.show(DELETE_IMAGE_ERROR_MESSAGE, { - type: 'error', - }), - ) - .finally(() => { - this.itemToDelete = {}; + return this.requestDeleteImage(this.itemToDelete) + .then(() => { + this.deleteAlertType = 'success'; + }) + .catch(() => { + this.deleteAlertType = 'danger'; }); }, encodeListItem(item) { const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); return window.btoa(params); }, + dismissDeleteAlert() { + this.deleteAlertType = null; + this.itemToDelete = {}; + }, }, }; </script> <template> <div class="w-100 slide-enter-from-element"> - <project-policy-alert v-if="!config.isGroupPage" /> + <gl-alert + v-if="showDeleteAlert" + :variant="deleteAlertType" + class="mt-2" + dismissible + @dismiss="dismissDeleteAlert" + > + <gl-sprintf :message="deleteImageAlertMessage"> + <template #title> + {{ itemToDelete.path }} + </template> + </gl-sprintf> + </gl-alert> + + <project-policy-alert v-if="!config.isGroupPage" class="mt-2" /> <gl-empty-state v-if="config.characterError" @@ -178,41 +204,57 @@ export default { v-for="(listItem, index) in images" :key="index" ref="rowItem" - :class="{ 'border-top': index === 0 }" - class="d-flex justify-content-between align-items-center py-2 border-bottom" + v-gl-tooltip="{ + placement: 'left', + disabled: !listItem.deleting, + title: $options.i18n.rowScheduledForDeletion, + }" > - <div> - <router-link - ref="detailsLink" - :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" - > - {{ listItem.path }} - </router-link> - <clipboard-button - v-if="listItem.location" - ref="clipboardButton" - :text="listItem.location" - :title="listItem.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </div> <div - v-gl-tooltip="{ disabled: listItem.destroy_path }" - class="d-none d-sm-block" - :title="$options.i18n.deleteButtonDisabled" + class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom" + :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }" > - <gl-deprecated-button - ref="deleteImageButton" - v-gl-tooltip - :disabled="!listItem.destroy_path" - :title="$options.i18n.removeRepositoryLabel" - :aria-label="$options.i18n.removeRepositoryLabel" - class="btn-inverted" - variant="danger" - @click="deleteImage(listItem)" + <div class="d-felx align-items-center"> + <router-link + ref="detailsLink" + :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" + > + {{ listItem.path }} + </router-link> + <clipboard-button + v-if="listItem.location" + ref="clipboardButton" + :disabled="listItem.deleting" + :text="listItem.location" + :title="listItem.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + <gl-icon + v-if="listItem.failedDelete" + v-gl-tooltip + :title="$options.i18n.asyncDeleteErrorMessage" + name="warning" + class="text-warning align-middle" + /> + </div> + <div + v-gl-tooltip="{ disabled: listItem.destroy_path }" + class="d-none d-sm-block" + :title="$options.i18n.deleteButtonDisabled" > - <gl-icon name="remove" /> - </gl-deprecated-button> + <gl-deprecated-button + ref="deleteImageButton" + v-gl-tooltip + :disabled="!listItem.destroy_path || listItem.deleting" + :title="$options.i18n.removeRepositoryLabel" + :aria-label="$options.i18n.removeRepositoryLabel" + class="btn-inverted" + variant="danger" + @click="deleteImage(listItem)" + > + <gl-icon name="remove" /> + </gl-deprecated-button> + </div> </div> </div> <gl-pagination diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index 2abd72cb9a8..b4f66dbbcd6 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -88,14 +88,12 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) }); }; -export const requestDeleteImage = ({ commit, dispatch, state }, destroyPath) => { +export const requestDeleteImage = ({ commit }, image) => { commit(types.SET_MAIN_LOADING, true); - return axios - .delete(destroyPath) + .delete(image.destroy_path) .then(() => { - dispatch('setShowGarbageCollectionTip', true); - dispatch('requestImagesList', { pagination: state.pagination }); + commit(types.UPDATE_IMAGE, { ...image, deleting: true }); }) .finally(() => { commit(types.SET_MAIN_LOADING, false); diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js index 86eaa0dd2f1..f32cdf90783 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js +++ b/app/assets/javascripts/registry/explorer/stores/mutation_types.js @@ -1,6 +1,7 @@ export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; +export const UPDATE_IMAGE = 'UPDATE_IMAGE'; export const SET_PAGINATION = 'SET_PAGINATION'; export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION'; diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js index fda788051c0..b25a0221dc1 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutations.js +++ b/app/assets/javascripts/registry/explorer/stores/mutations.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants'; export default { [types.SET_INITIAL_STATE](state, config) { @@ -12,7 +13,17 @@ export default { }, [types.SET_IMAGES_LIST_SUCCESS](state, images) { - state.images = images; + state.images = images.map(i => ({ + ...i, + status: undefined, + deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS, + failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS, + })); + }, + + [types.UPDATE_IMAGE](state, image) { + const index = state.images.findIndex(i => i.id === image.id); + state.images.splice(index, 1, { ...image }); }, [types.SET_TAGS_LIST_SUCCESS](state, tags) { diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue index 6aae9195be1..256b0e33e79 100644 --- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue +++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue @@ -26,18 +26,11 @@ export default { * The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation. * Here we simply split the string on `.` and get the code in the 5th position */ - if (this.issue.code === undefined) { - return null; - } - - return this.issue.code.split('.')[4] || null; + return this.issue.code?.split('.')[4]; }, learnMoreUrl() { - if (this.parsedTECHSCode === null) { - return 'https://www.w3.org/TR/WCAG20-TECHS/Overview.html'; - } - - return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode}.html`; + // eslint-disable-next-line @gitlab/require-i18n-strings + return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode || 'Overview'}.html`; }, }, }; diff --git a/app/assets/javascripts/reports/accessibility_report/store/actions.js b/app/assets/javascripts/reports/accessibility_report/store/actions.js new file mode 100644 index 00000000000..f145b352e7d --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/actions.js @@ -0,0 +1,47 @@ +import axios from '~/lib/utils/axios_utils'; +import * as types from './mutation_types'; +import { parseAccessibilityReport, compareAccessibilityReports } from './utils'; +import { s__ } from '~/locale'; + +export const fetchReport = ({ state, dispatch, commit }) => { + commit(types.REQUEST_REPORT); + + // If we don't have both endpoints, throw an error. + if (!state.baseEndpoint || !state.headEndpoint) { + commit( + types.RECEIVE_REPORT_ERROR, + s__('AccessibilityReport|Accessibility report artifact not found'), + ); + return; + } + + Promise.all([ + axios.get(state.baseEndpoint).then(response => ({ + ...response.data, + isHead: false, + })), + axios.get(state.headEndpoint).then(response => ({ + ...response.data, + isHead: true, + })), + ]) + .then(responses => dispatch('receiveReportSuccess', responses)) + .catch(() => + commit( + types.RECEIVE_REPORT_ERROR, + s__('AccessibilityReport|Failed to retrieve accessibility report'), + ), + ); +}; + +export const receiveReportSuccess = ({ commit }, responses) => { + const parsedReports = responses.map(response => ({ + isHead: response.isHead, + issues: parseAccessibilityReport(response), + })); + const report = compareAccessibilityReports(parsedReports); + commit(types.RECEIVE_REPORT_SUCCESS, report); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/reports/accessibility_report/store/index.js b/app/assets/javascripts/reports/accessibility_report/store/index.js new file mode 100644 index 00000000000..c1413499802 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default initialState => + new Vuex.Store({ + actions, + mutations, + state: state(initialState), + }); diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js new file mode 100644 index 00000000000..381736bbd38 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js @@ -0,0 +1,3 @@ +export const REQUEST_REPORT = 'REQUEST_REPORT'; +export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS'; +export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR'; diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutations.js b/app/assets/javascripts/reports/accessibility_report/store/mutations.js new file mode 100644 index 00000000000..66cf9f3d69d --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/mutations.js @@ -0,0 +1,18 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_REPORT](state) { + state.isLoading = true; + }, + [types.RECEIVE_REPORT_SUCCESS](state, report) { + state.hasError = false; + state.isLoading = false; + state.report = report; + }, + [types.RECEIVE_REPORT_ERROR](state, message) { + state.isLoading = false; + state.hasError = true; + state.errorMessage = message; + state.report = {}; + }, +}; diff --git a/app/assets/javascripts/reports/accessibility_report/store/state.js b/app/assets/javascripts/reports/accessibility_report/store/state.js new file mode 100644 index 00000000000..7d560a9f419 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/state.js @@ -0,0 +1,30 @@ +export default (initialState = {}) => ({ + baseEndpoint: initialState.baseEndpoint || '', + headEndpoint: initialState.headEndpoint || '', + + isLoading: initialState.isLoading || false, + hasError: initialState.hasError || false, + + /** + * Report will have the following format: + * { + * status: {String}, + * summary: { + * total: {Number}, + * notes: {Number}, + * warnings: {Number}, + * errors: {Number}, + * }, + * existing_errors: {Array.<Object>}, + * existing_notes: {Array.<Object>}, + * existing_warnings: {Array.<Object>}, + * new_errors: {Array.<Object>}, + * new_notes: {Array.<Object>}, + * new_warnings: {Array.<Object>}, + * resolved_errors: {Array.<Object>}, + * resolved_notes: {Array.<Object>}, + * resolved_warnings: {Array.<Object>}, + * } + */ + report: initialState.report || {}, +}); diff --git a/app/assets/javascripts/reports/accessibility_report/store/utils.js b/app/assets/javascripts/reports/accessibility_report/store/utils.js new file mode 100644 index 00000000000..f2de65445b0 --- /dev/null +++ b/app/assets/javascripts/reports/accessibility_report/store/utils.js @@ -0,0 +1,83 @@ +import { difference, intersection } from 'lodash'; +import { + STATUS_FAILED, + STATUS_SUCCESS, + ACCESSIBILITY_ISSUE_ERROR, + ACCESSIBILITY_ISSUE_WARNING, +} from '../../constants'; + +export const parseAccessibilityReport = data => { + // Combine all issues into one array + return Object.keys(data.results) + .map(key => [...data.results[key]]) + .flat() + .map(issue => JSON.stringify(issue)); // stringify to help with comparisons +}; + +export const compareAccessibilityReports = reports => { + const result = { + status: '', + summary: { + total: 0, + notes: 0, + errors: 0, + warnings: 0, + }, + new_errors: [], + new_notes: [], + new_warnings: [], + resolved_errors: [], + resolved_notes: [], + resolved_warnings: [], + existing_errors: [], + existing_notes: [], + existing_warnings: [], + }; + + const headReport = reports.filter(report => report.isHead)[0]; + const baseReport = reports.filter(report => !report.isHead)[0]; + + // existing issues are those that exist in both the head report and the base report + const existingIssues = intersection(headReport.issues, baseReport.issues); + // new issues are those that exist in only the head report + const newIssues = difference(headReport.issues, baseReport.issues); + // resolved issues are those that exist in only the base report + const resolvedIssues = difference(baseReport.issues, headReport.issues); + + const parseIssues = (issue, issueType, shouldCount) => { + const parsedIssue = JSON.parse(issue); + switch (parsedIssue.type) { + case ACCESSIBILITY_ISSUE_ERROR: + result[`${issueType}_errors`].push(parsedIssue); + if (shouldCount) { + result.summary.errors += 1; + } + break; + case ACCESSIBILITY_ISSUE_WARNING: + result[`${issueType}_warnings`].push(parsedIssue); + if (shouldCount) { + result.summary.warnings += 1; + } + break; + default: + result[`${issueType}_notes`].push(parsedIssue); + if (shouldCount) { + result.summary.notes += 1; + } + break; + } + }; + + existingIssues.forEach(issue => parseIssues(issue, 'existing', true)); + newIssues.forEach(issue => parseIssues(issue, 'new', true)); + resolvedIssues.forEach(issue => parseIssues(issue, 'resolved', false)); + + result.summary.total = result.summary.errors + result.summary.warnings + result.summary.notes; + const hasErrorsOrWarnings = result.summary.errors > 0 || result.summary.warnings > 0; + result.status = hasErrorsOrWarnings ? STATUS_FAILED : STATUS_SUCCESS; + + return result; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index 88d174f96ed..0f7a0e60dc0 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -1,6 +1,6 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { s__ } from '~/locale'; +import { sprintf, s__ } from '~/locale'; import { componentNames } from './issue_body'; import ReportSection from './report_section.vue'; import SummaryRow from './summary_row.vue'; @@ -52,8 +52,17 @@ export default { methods: { ...mapActions(['setEndpoint', 'fetchReports']), reportText(report) { - const summary = report.summary || {}; - return reportTextBuilder(report.name, summary); + const { name, summary } = report || {}; + + if (report.status === 'error') { + return sprintf(s__('Reports|An error occurred while loading %{name} results'), { name }); + } + + if (!report.name) { + return s__('Reports|An error occured while loading report'); + } + + return reportTextBuilder(name, summary); }, getReportIcon(report) { return statusIcon(report.status); diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index 1845b51e6b2..b3905cbfcfb 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -22,3 +22,6 @@ export const status = { ERROR: 'ERROR', SUCCESS: 'SUCCESS', }; + +export const ACCESSIBILITY_ISSUE_ERROR = 'error'; +export const ACCESSIBILITY_ISSUE_WARNING = 'warning'; diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js index 68f6de3a7ee..35ab72bf694 100644 --- a/app/assets/javascripts/reports/store/mutations.js +++ b/app/assets/javascripts/reports/store/mutations.js @@ -8,8 +8,7 @@ export default { state.isLoading = true; }, [types.RECEIVE_REPORTS_SUCCESS](state, response) { - // Make sure to clean previous state in case it was an error - state.hasError = false; + state.hasError = response.suites.some(suite => suite.status === 'error'); state.isLoading = false; diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 6c58f48dc74..d78b2d9d962 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -108,14 +108,14 @@ export default { return acc.concat({ name, path, - to: `/-/tree/${joinPaths(escape(this.ref), path)}`, + to: `/-/tree/${joinPaths(escapeFileUrl(this.ref), path)}`, }); }, [ { name: this.projectShortPath, path: '/', - to: `/-/tree/${escape(this.ref)}/`, + to: `/-/tree/${escapeFileUrl(this.ref)}/`, }, ], ); diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue index f9fcbc356e8..0a8ee5f2fc5 100644 --- a/app/assets/javascripts/repository/components/table/parent_row.vue +++ b/app/assets/javascripts/repository/components/table/parent_row.vue @@ -1,5 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; +import { escapeFileUrl } from '~/lib/utils/url_utility'; export default { components: { @@ -28,7 +29,7 @@ export default { return splitArray.map(p => encodeURIComponent(p)).join('/'); }, parentRoute() { - return { path: `/-/tree/${escape(this.commitRef)}/${this.parentPath}` }; + return { path: `/-/tree/${escapeFileUrl(this.commitRef)}/${this.parentPath}` }; }, }, methods: { diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 00ccc49d770..6bd1c702a82 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -99,7 +99,7 @@ export default { computed: { routerLinkTo() { return this.isFolder - ? { path: `/-/tree/${escape(this.ref)}/${escapeFileUrl(this.path)}` } + ? { path: `/-/tree/${escapeFileUrl(this.ref)}/${escapeFileUrl(this.path)}` } : null; }, isFolder() { diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 0c68b5a599b..6640b636597 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -48,7 +48,7 @@ const defaultClient = createDefaultClient( case 'TreeEntry': case 'Submodule': case 'Blob': - return `${escape(obj.flatPath)}-${obj.id}`; + return `${encodeURIComponent(obj.flatPath)}-${obj.id}`; default: // If the type doesn't match any of the above we fallback // to using the default Apollo ID diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 637060f6ed9..05783fc3b5d 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -100,7 +100,9 @@ export default function setupVueRepositoryList() { render(h) { return h(TreeActionLink, { props: { - path: `${historyLink}/${this.$route.params.path ? escape(this.$route.params.path) : ''}`, + path: `${historyLink}/${ + this.$route.params.path ? encodeURIComponent(this.$route.params.path) : '' + }`, text: __('History'), }, }); diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 550ec3cb0d1..0bb33de0234 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -1,7 +1,6 @@ /* eslint-disable func-names, consistent-return, no-param-reassign */ import $ from 'jquery'; -import _ from 'underscore'; import Cookies from 'js-cookie'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -142,7 +141,7 @@ Sidebar.prototype.sidebarCollapseClicked = function(e) { }; Sidebar.prototype.openDropdown = function(blockOrName) { - const $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; + const $block = typeof blockOrName === 'string' ? this.getBlock(blockOrName) : blockOrName; if (!this.isOpen()) { this.setCollapseAfterUpdate($block); this.toggleSidebar('open'); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 3eaa34c8a93..0e32bb5e49f 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,7 +1,7 @@ /* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; -import { escape, throttle } from 'underscore'; +import { escape as esc, throttle } from 'lodash'; import { s__, __ } from '~/locale'; import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper'; import axios from './lib/utils/axios_utils'; @@ -448,7 +448,7 @@ export class SearchAutocomplete { const avatar = avatarUrl ? `<img class="search-item-avatar" src="${avatarUrl}" />` : `<div class="s16 avatar identicon ${getIdenticonBackgroundClass(id)}">${getIdenticonTitle( - escape(label), + esc(label), )}</div>`; return avatar; diff --git a/app/assets/javascripts/snippet/snippet_edit.js b/app/assets/javascripts/snippet/snippet_edit.js index a098d17a226..b0d373b1a4b 100644 --- a/app/assets/javascripts/snippet/snippet_edit.js +++ b/app/assets/javascripts/snippet/snippet_edit.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import initSnippet from '~/snippet/snippet_bundle'; import ZenMode from '~/zen_mode'; import GLForm from '~/gl_form'; +import { SnippetEditInit } from '~/snippets'; document.addEventListener('DOMContentLoaded', () => { const form = document.querySelector('.snippet-form'); @@ -17,9 +18,15 @@ document.addEventListener('DOMContentLoaded', () => { const projectSnippetOptions = {}; const options = - form.dataset.snippetType === 'project' ? projectSnippetOptions : personalSnippetOptions; + form.dataset.snippetType === 'project' || form.dataset.projectPath + ? projectSnippetOptions + : personalSnippetOptions; - initSnippet(); + if (gon?.features?.snippetsEditVue) { + SnippetEditInit(); + } else { + initSnippet(); + new GLForm($(form), options); // eslint-disable-line no-new + } new ZenMode(); // eslint-disable-line no-new - new GLForm($(form), options); // eslint-disable-line no-new }); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue new file mode 100644 index 00000000000..7f93014b93b --- /dev/null +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -0,0 +1,216 @@ +<script> +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; + +import Flash from '~/flash'; +import { __, sprintf } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; +import TitleField from '~/vue_shared/components/form/title.vue'; +import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility'; +import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; + +import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; +import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql'; +import { getSnippetMixin } from '../mixins/snippets'; +import { SNIPPET_VISIBILITY_PRIVATE } from '../constants'; +import SnippetBlobEdit from './snippet_blob_edit.vue'; +import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; +import SnippetDescriptionEdit from './snippet_description_edit.vue'; + +export default { + components: { + SnippetDescriptionEdit, + SnippetVisibilityEdit, + SnippetBlobEdit, + TitleField, + FormFooterActions, + GlButton, + GlLoadingIcon, + }, + mixins: [getSnippetMixin], + props: { + markdownPreviewPath: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, + visibilityHelpLink: { + type: String, + default: '', + required: false, + }, + projectPath: { + type: String, + default: '', + required: false, + }, + }, + data() { + return { + blob: {}, + fileName: '', + content: '', + isContentLoading: true, + isUpdating: false, + newSnippet: false, + }; + }, + computed: { + updatePrevented() { + return this.snippet.title === '' || this.content === '' || this.isUpdating; + }, + isProjectSnippet() { + return Boolean(this.projectPath); + }, + apiData() { + return { + id: this.snippet.id, + title: this.snippet.title, + description: this.snippet.description, + visibilityLevel: this.snippet.visibilityLevel, + fileName: this.fileName, + content: this.content, + }; + }, + saveButtonLabel() { + if (this.newSnippet) { + return __('Create snippet'); + } + return this.isUpdating ? __('Saving') : __('Save changes'); + }, + cancelButtonHref() { + if (this.newSnippet) { + return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`; + } + return this.snippet.webUrl; + }, + titleFieldId() { + return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`; + }, + descriptionFieldId() { + return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`; + }, + }, + methods: { + updateFileName(newName) { + this.fileName = newName; + }, + flashAPIFailure(err) { + Flash(sprintf(__("Can't update snippet: %{err}"), { err })); + }, + onNewSnippetFetched() { + this.newSnippet = true; + this.snippet = this.$options.newSnippetSchema; + this.blob = this.snippet.blob; + this.isContentLoading = false; + }, + onExistingSnippetFetched() { + this.newSnippet = false; + const { blob } = this.snippet; + this.blob = blob; + this.fileName = blob.name; + const baseUrl = getBaseURL(); + const url = joinPaths(baseUrl, blob.rawPath); + + axios + .get(url) + .then(res => { + this.content = res.data; + this.isContentLoading = false; + }) + .catch(e => this.flashAPIFailure(e)); + }, + onSnippetFetch(snippetRes) { + if (snippetRes.data.snippets.edges.length === 0) { + this.onNewSnippetFetched(); + } else { + this.onExistingSnippetFetched(); + } + }, + handleFormSubmit() { + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: this.newSnippet ? CreateSnippetMutation : UpdateSnippetMutation, + variables: { + input: { + ...this.apiData, + projectPath: this.newSnippet ? this.projectPath : undefined, + }, + }, + }) + .then(({ data }) => { + const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet; + + const errors = baseObj?.errors; + if (errors.length) { + this.flashAPIFailure(errors[0]); + } + redirectTo(baseObj.snippet.webUrl); + }) + .catch(e => { + this.isUpdating = false; + this.flashAPIFailure(e); + }); + }, + }, + newSnippetSchema: { + title: '', + description: '', + visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, + blob: {}, + }, +}; +</script> +<template> + <form + class="snippet-form js-requires-input js-quick-submit common-note-form" + :data-snippet-type="isProjectSnippet ? 'project' : 'personal'" + > + <gl-loading-icon + v-if="isLoading" + :label="__('Loading snippet')" + size="lg" + class="loading-animation prepend-top-20 append-bottom-20" + /> + <template v-else> + <title-field :id="titleFieldId" v-model="snippet.title" required :autofocus="true" /> + <snippet-description-edit + :id="descriptionFieldId" + v-model="snippet.description" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + /> + <snippet-blob-edit + v-model="content" + :file-name="fileName" + :is-loading="isContentLoading" + @name-change="updateFileName" + /> + <snippet-visibility-edit + v-model="snippet.visibilityLevel" + :help-link="visibilityHelpLink" + :is-project-snippet="isProjectSnippet" + /> + <form-footer-actions> + <template #prepend> + <gl-button + type="submit" + category="primary" + variant="success" + :disabled="updatePrevented" + @click="handleFormSubmit" + >{{ saveButtonLabel }}</gl-button + > + </template> + <template #append> + <gl-button data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{ + __('Cancel') + }}</gl-button> + </template> + </form-footer-actions> + </template> + </form> +</template> diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue index 68810f8ab3f..6f3a86be8d7 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue @@ -50,7 +50,6 @@ export default { :markdown-docs-path="markdownDocsPath" > <textarea - id="snippet-description" slot="textarea" class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" @@ -59,6 +58,7 @@ export default { :value="value" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" + v-bind="$attrs" @input="$emit('input', $event.target.value)" > </textarea> diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index b826110117c..1c79492957d 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -3,7 +3,8 @@ import Translate from '~/vue_shared/translate'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import SnippetsApp from './components/show.vue'; +import SnippetsShow from './components/show.vue'; +import SnippetsEdit from './components/edit.vue'; Vue.use(VueApollo); Vue.use(Translate); @@ -31,7 +32,11 @@ function appFactory(el, Component) { } export const SnippetShowInit = () => { - appFactory(document.getElementById('js-snippet-view'), SnippetsApp); + appFactory(document.getElementById('js-snippet-view'), SnippetsShow); +}; + +export const SnippetEditInit = () => { + appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit); }; export default () => {}; diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql new file mode 100644 index 00000000000..f688868d1b9 --- /dev/null +++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql @@ -0,0 +1,8 @@ +mutation CreateSnippet($input: CreateSnippetInput!) { + createSnippet(input: $input) { + errors + snippet { + webUrl + } + } +} diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql new file mode 100644 index 00000000000..548725f7357 --- /dev/null +++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql @@ -0,0 +1,8 @@ +mutation UpdateSnippet($input: UpdateSnippetInput!) { + updateSnippet(input: $input) { + errors + snippet { + webUrl + } + } +} diff --git a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue index f8cc6d1630b..82917319fc3 100644 --- a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue +++ b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue @@ -4,6 +4,7 @@ import { GlSkeletonLoader } from '@gitlab/ui'; import EditArea from './edit_area.vue'; import EditHeader from './edit_header.vue'; +import SavedChangesMessage from './saved_changes_message.vue'; import Toolbar from './publish_toolbar.vue'; import InvalidContentMessage from './invalid_content_message.vue'; import SubmitChangesError from './submit_changes_error.vue'; @@ -14,6 +15,7 @@ export default { EditHeader, InvalidContentMessage, GlSkeletonLoader, + SavedChangesMessage, Toolbar, SubmitChangesError, }, @@ -27,6 +29,7 @@ export default { 'returnUrl', 'title', 'submitChangesError', + 'savedContentMeta', ]), ...mapGetters(['contentChanged']), }, @@ -41,8 +44,18 @@ export default { }; </script> <template> - <div class="d-flex justify-content-center h-100 pt-2"> - <template v-if="isSupportedContent"> + <div class="d-flex justify-content-center h-100 pt-2"> + <!-- Success view --> + <saved-changes-message + v-if="savedContentMeta" + :branch="savedContentMeta.branch" + :commit="savedContentMeta.commit" + :merge-request="savedContentMeta.mergeRequest" + :return-url="returnUrl" + /> + + <!-- Main view --> + <template v-else-if="isSupportedContent"> <div v-if="isLoadingContent" class="w-50 h-50"> <gl-skeleton-loader :width="500" :height="102"> <rect width="500" height="16" rx="4" /> @@ -75,6 +88,8 @@ export default { /> </div> </template> + + <!-- Error view --> <invalid-content-message v-else class="w-75" /> </div> </template> diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index 09fe952e5f0..42ab44aa03c 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -1,4 +1,4 @@ -import _ from 'underscore'; +import { omitBy, isUndefined } from 'lodash'; const DEFAULT_SNOWPLOW_OPTIONS = { namespace: 'gl', @@ -29,7 +29,7 @@ const eventHandler = (e, func, opts = {}) => { context: el.dataset.trackContext, }; - func(opts.category, action + (opts.suffix || ''), _.omit(data, _.isUndefined)); + func(opts.category, action + (opts.suffix || ''), omitBy(data, isUndefined)); }; const eventHandlers = (category, func) => { diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index f8c1c3634c2..bde00d72620 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -38,8 +38,7 @@ const populateUserInfo = user => { name: userData.name, location: userData.location, bio: userData.bio, - organization: userData.organization, - jobTitle: userData.job_title, + workInformation: userData.work_information, loaded: true, }); } @@ -71,7 +70,7 @@ export default (elements = document.querySelectorAll('.js-user-link')) => { const user = { location: null, bio: null, - organization: null, + workInformation: null, status: null, loaded: false, }; diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 6821df57b5a..debf8c57b43 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -3,7 +3,7 @@ /* global emitSidebarEvent */ import $ from 'jquery'; -import _ from 'underscore'; +import { escape as esc, template, uniqBy } from 'lodash'; import axios from './lib/utils/axios_utils'; import { s__, __, sprintf } from './locale'; import ModalStore from './boards/stores/modal_store'; @@ -81,7 +81,7 @@ function UsersSelect(currentUser, els, options = {}) { const userName = currentUserInfo.name; const userId = currentUserInfo.id || currentUser.id; - const inputHtmlString = _.template(` + const inputHtmlString = template(` <input type="hidden" name="<%- fieldName %>" data-meta="<%- userName %>" value="<%- userId %>" /> @@ -205,7 +205,7 @@ function UsersSelect(currentUser, els, options = {}) { username: data.assignee.username, avatar: data.assignee.avatar_url, }; - tooltipTitle = _.escape(user.name); + tooltipTitle = esc(user.name); } else { user = { name: s__('UsersSelect|Unassigned'), @@ -219,10 +219,10 @@ function UsersSelect(currentUser, els, options = {}) { return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); }); }; - collapsedAssigneeTemplate = _.template( + collapsedAssigneeTemplate = template( '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', ); - assigneeTemplate = _.template( + assigneeTemplate = template( `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { openingTag: '<a href="#" class="js-assign-yourself">', @@ -248,7 +248,7 @@ function UsersSelect(currentUser, els, options = {}) { // Potential duplicate entries when dealing with issue board // because issue board is also managed by vue - const selectedUsers = _.uniq(selectedInputs, false, a => a.value) + const selectedUsers = uniqBy(selectedInputs, a => a.value) .filter(input => { const userId = parseInt(input.value, 10); const inUsersArray = users.find(u => u.id === userId); @@ -543,7 +543,7 @@ function UsersSelect(currentUser, els, options = {}) { let img = ''; if (user.beforeDivider != null) { - `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape( + `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${esc( user.name, )}</a></li>`; } else { @@ -672,10 +672,10 @@ UsersSelect.prototype.formatResult = function(user) { </div> <div class='user-info'> <div class='user-name dropdown-menu-user-full-name'> - ${_.escape(user.name)} + ${esc(user.name)} </div> <div class='user-username dropdown-menu-user-username text-secondary'> - ${!user.invite ? `@${_.escape(user.username)}` : ''} + ${!user.invite ? `@${esc(user.username)}` : ''} </div> </div> </div> @@ -683,7 +683,7 @@ UsersSelect.prototype.formatResult = function(user) { }; UsersSelect.prototype.formatSelection = function(user) { - return _.escape(user.name); + return esc(user.name); }; UsersSelect.prototype.user = function(user_id, callback) { @@ -746,7 +746,7 @@ UsersSelect.prototype.renderRow = function(issuableType, user, selected, usernam ${this.renderRowAvatar(issuableType, user, img)} <span class="d-flex flex-column overflow-hidden"> <strong class="dropdown-menu-user-full-name"> - ${_.escape(user.name)} + ${esc(user.name)} </strong> ${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''} </span> diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue new file mode 100644 index 00000000000..848295cc984 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -0,0 +1,178 @@ +<script> +import { groupBy } from 'lodash'; +import { GlIcon } from '@gitlab/ui'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { glEmojiTag } from '../../emoji'; +import { __, sprintf } from '~/locale'; + +// Internal constant, specific to this component, used when no `currentUserId` is given +const NO_USER_ID = -1; + +export default { + components: { + GlIcon, + }, + directives: { + tooltip, + }, + props: { + awards: { + type: Array, + required: true, + }, + canAwardEmoji: { + type: Boolean, + required: true, + }, + currentUserId: { + type: Number, + required: false, + default: NO_USER_ID, + }, + addButtonClass: { + type: String, + required: false, + default: '', + }, + }, + computed: { + groupedAwards() { + const { thumbsup, thumbsdown, ...rest } = groupBy(this.awards, x => x.name); + + return [ + ...(thumbsup ? [this.createAwardList('thumbsup', thumbsup)] : []), + ...(thumbsdown ? [this.createAwardList('thumbsdown', thumbsdown)] : []), + ...Object.entries(rest).map(([name, list]) => this.createAwardList(name, list)), + ]; + }, + isAuthoredByMe() { + return this.noteAuthorId === this.currentUserId; + }, + }, + methods: { + getAwardClassBindings(awardList) { + return { + active: this.hasReactionByCurrentUser(awardList), + disabled: this.currentUserId === NO_USER_ID, + }; + }, + hasReactionByCurrentUser(awardList) { + if (this.currentUserId === NO_USER_ID) { + return false; + } + + return awardList.some(award => award.user.id === this.currentUserId); + }, + createAwardList(name, list) { + return { + name, + list, + title: this.getAwardListTitle(list), + classes: this.getAwardClassBindings(list), + html: glEmojiTag(name), + }; + }, + getAwardListTitle(awardsList) { + const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); + const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; + let awardList = awardsList; + + // Filter myself from list if I am awarded. + if (hasReactionByCurrentUser) { + awardList = awardList.filter(award => award.user.id !== this.currentUserId); + } + + // Get only 9-10 usernames to show in tooltip text. + const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); + + // Get the remaining list to use in `and x more` text. + const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); + + // Add myself to the beginning of the list so title will start with You. + if (hasReactionByCurrentUser) { + namesToShow.unshift(__('You')); + } + + let title = ''; + + // We have 10+ awarded user, join them with comma and add `and x more`. + if (remainingAwardList.length) { + title = sprintf( + __(`%{listToShow}, and %{awardsListLength} more.`), + { + listToShow: namesToShow.join(', '), + awardsListLength: remainingAwardList.length, + }, + false, + ); + } else if (namesToShow.length > 1) { + // Join all names with comma but not the last one, it will be added with and text. + title = namesToShow.slice(0, namesToShow.length - 1).join(', '); + // If we have more than 2 users we need an extra comma before and text. + title += namesToShow.length > 2 ? ',' : ''; + title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text + } else { + // We have only 2 users so join them with and. + title = namesToShow.join(__(' and ')); + } + + return title; + }, + handleAward(awardName) { + if (!this.canAwardEmoji) { + return; + } + + // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string + const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName; + + this.$emit('award', parsedName); + }, + }, +}; +</script> + +<template> + <div class="awards js-awards-block"> + <button + v-for="awardList in groupedAwards" + :key="awardList.name" + v-tooltip + :class="awardList.classes" + :title="awardList.title" + data-boundary="viewport" + data-testid="award-button" + class="btn award-control" + type="button" + @click="handleAward(awardList.name)" + > + <span data-testid="award-html" v-html="awardList.html"></span> + <span class="award-control-text js-counter">{{ awardList.list.length }}</span> + </button> + <div v-if="canAwardEmoji" class="award-menu-holder"> + <button + v-tooltip + :class="addButtonClass" + class="award-control btn js-add-award" + title="Add reaction" + :aria-label="__('Add reaction')" + data-boundary="viewport" + type="button" + > + <span class="award-control-icon award-control-icon-neutral"> + <gl-icon aria-hidden="true" name="slight-smile" /> + </span> + <span class="award-control-icon award-control-icon-positive"> + <gl-icon aria-hidden="true" name="smiley" /> + </span> + <span class="award-control-icon award-control-icon-super-positive"> + <gl-icon aria-hidden="true" name="smiley" /> + </span> + <i + aria-hidden="true" + class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading" + ></i> + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index cdcd5cdef7f..ffc616d7309 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -158,7 +158,7 @@ export default { <template> <tooltip-on-truncate :title="timeWindowText" - :truncate-target="elem => elem.querySelector('.date-time-picker-toggle')" + :truncate-target="elem => elem.querySelector('.gl-dropdown-toggle-text')" placement="top" class="d-inline-block" > diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue index f8f70529bd1..fad69dc1e24 100644 --- a/app/assets/javascripts/vue_shared/components/form/title.vue +++ b/app/assets/javascripts/vue_shared/components/form/title.vue @@ -10,6 +10,6 @@ export default { </script> <template> <gl-form-group :label="__('Title')" label-for="title-field-edit"> - <gl-form-input id="title-field-edit" v-bind="$attrs" v-on="$listeners" /> + <gl-form-input v-bind="$attrs" v-on="$listeners" /> </gl-form-group> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue index 913c971a512..040a15406e0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue @@ -37,7 +37,7 @@ export default { :title="tooltipLabel" :class="cssClasses" type="button" - class="btn btn-blank gutter-toggle btn-sidebar-action" + class="btn btn-blank gutter-toggle btn-sidebar-action js-sidebar-vue-toggle" data-container="body" data-placement="left" data-boundary="viewport" diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 602d4ab89e1..595baeeb14f 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -1,10 +1,8 @@ <script> -import { GlPopover, GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; -import { s__ } from '~/locale'; -import { isString } from 'lodash'; export default { name: 'UserPopover', @@ -12,7 +10,6 @@ export default { Icon, GlPopover, GlSkeletonLoading, - GlSprintf, UserAvatarImage, }, props: { @@ -49,26 +46,7 @@ export default { return !this.user.name; }, workInformationIsLoading() { - return !this.user.loaded && this.workInformation === null; - }, - workInformation() { - const { jobTitle, organization } = this.user; - - if (organization && jobTitle) { - return { - message: s__('Profile|%{job_title} at %{organization}'), - placeholders: { job_title: jobTitle, organization }, - }; - } else if (organization) { - return organization; - } else if (jobTitle) { - return jobTitle; - } - - return null; - }, - workInformationShouldUseSprintf() { - return !isString(this.workInformation); + return !this.user.loaded && this.user.workInformation === null; }, locationIsLoading() { return !this.user.loaded && this.user.location === null; @@ -98,23 +76,13 @@ export default { <icon name="profile" class="category-icon flex-shrink-0" /> <span ref="bio" class="ml-1">{{ user.bio }}</span> </div> - <div v-if="workInformation" class="d-flex mb-1"> + <div v-if="user.workInformation" class="d-flex mb-1"> <icon v-show="!workInformationIsLoading" name="work" class="category-icon flex-shrink-0" /> - <span ref="workInformation" class="ml-1"> - <gl-sprintf v-if="workInformationShouldUseSprintf" :message="workInformation.message"> - <template - v-for="(placeholder, slotName) in workInformation.placeholders" - v-slot:[slotName] - > - <span :key="slotName">{{ placeholder }}</span> - </template> - </gl-sprintf> - <span v-else>{{ workInformation }}</span> - </span> + <span ref="workInformation" class="ml-1">{{ user.workInformation }}</span> </div> <gl-skeleton-loading v-if="workInformationIsLoading" diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index 6820bdca2fa..ce1039832d3 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -73,6 +73,11 @@ $item-weight-max-width: 48px; .issue-token-state-icon-closed { display: none; } + + .sortable-link { + color: $gray-900; + font-weight: normal; + } } .item-path-id .path-id-text, @@ -249,6 +254,12 @@ $item-weight-max-width: 48px; line-height: 0; } +@include media-breakpoint-down(xs) { + .btn-sm.dropdown-toggle-split { + max-width: 40px; + } +} + @include media-breakpoint-up(sm) { .item-info-area { flex-basis: 100%; @@ -296,10 +307,6 @@ $item-weight-max-width: 48px; .item-meta { .item-meta-child { flex-basis: unset; - - ~ .item-assignees { - margin-left: $gl-padding-4; - } } } @@ -353,7 +360,7 @@ $item-weight-max-width: 48px; } .item-title-wrapper { - max-width: calc(100% - 440px); + max-width: calc(100% - 500px); } .item-info-area { @@ -407,7 +414,7 @@ $item-weight-max-width: 48px; } } -@media only screen and (min-width: 1400px) { +@media only screen and (min-width: 1500px) { .card-header, .item-body { .health-label-short { @@ -419,7 +426,9 @@ $item-weight-max-width: 48px; } } - .item-body .item-title-wrapper { - max-width: calc(100% - 570px); + .item-body { + .item-title-wrapper { + max-width: calc(100% - 640px); + } } } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 816dbc6931c..aaad640b7f0 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -533,6 +533,17 @@ margin: 0; font-size: $gl-font-size-small; } + + ul.dropdown-menu { + margin-top: 4px; + margin-bottom: 24px; + padding: 8px 0; + + li { + margin: 0; + padding: 0 1px; + } + } } } diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 024c1781bf8..c5869880af9 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -3,6 +3,8 @@ @import './ide_mixins'; @import './ide_monaco_overrides'; +@import './themes/dark'; + $search-list-icon-width: 18px; $ide-activity-bar-width: 60px; $ide-context-header-padding: 10px; diff --git a/app/assets/stylesheets/page_bundles/themes/_dark.scss b/app/assets/stylesheets/page_bundles/themes/_dark.scss new file mode 100644 index 00000000000..faadf31a87e --- /dev/null +++ b/app/assets/stylesheets/page_bundles/themes/_dark.scss @@ -0,0 +1,296 @@ +.ide.theme-dark { + $border-color: #1d1f21; + $highlight-accent: #fff; + $text-color: #ccc; + $background: #333; + $background-hover: #2d2d2d; + $highlight-background: #252526; + $link-color: #428fdc; + $footer-background: #060606; + + $input-border: #868686; + $input-background: transparent; + $input-color: $highlight-accent; + + $btn-default-background: transparent; + $btn-default-border: #bfbfbf; + $btn-default-hover-border: #d8d8d8; + + $btn-primary-background: #1068bf; + $btn-primary-border: #428fdc; + $btn-primary-hover-border: #63a6e9; + + $btn-success-background: #217645; + $btn-success-border: #108548; + $btn-success-hover-border: #2da160; + + $btn-disabled-border: rgba(223, 223, 223, 0.24); + $btn-disabled-color: rgba(145, 145, 145, 0.48); + + a { + color: $link-color; + } + + h1, + h2, + h3, + h4:not(.modal-title), + h5, + h6, + .md, + .md p, + .ide-view, + .context-header > a, + .ide-sidebar-link, + .multi-file-tab-close, + .ide-tree-header button, + .ide-status-bar, + input, + textarea, + .md-area.is-focused, + .ide-entry-dropdown-toggle, + .nav-links:not(.quick-links) li:not(.md-header-toolbar) a:hover { + color: $text-color; + } + + .modal-body { + color: $gl-text-color; + } + + .dropdown-menu-toggle svg, + .dropdown-menu-toggle svg:hover, + .ide-tree-header svg, + .file-row .file-row-icon svg, + .file-row:hover .file-row-icon svg { + fill: $text-color; + } + + .multi-file-tab-close:hover { + background-color: $input-border; + } + + .ide-review-sub-header:hover { + color: $input-border; + } + + .text-secondary { + color: $text-color !important; + } + + input[type='text']::placeholder, + textarea::placeholder { + color: $input-border; + } + + .ide-staged-action-btn { + background-color: transparent; + } + + .multi-file-commit-panel, + .multi-file-tabs, + .multi-file-tabs li, + .file-row:hover, + .file-row:focus, + .multi-file-commit-list-path:hover, + .multi-file-commit-list-path:focus, + .multi-file-commit-list-path.is-active, + .file-row.is-active, + .ide-commit-editor-header, + .ide-file-templates, + .ide-entry-dropdown-toggle, + .ide-staged-action-btn { + background-color: $background; + } + + .ide-sidebar-link:hover { + background-color: $background-hover; + } + + .common-note-form .md-area { + border-color: $input-border; + } + + &, + .multi-file-commit-panel-inner-content, + .multi-file-commit-form, + .multi-file-tabs li.active, + .ide-sidebar-link.active, + .ide-sidebar-link.active::after, + .ide-right-sidebar .multi-file-commit-panel-inner, + .common-note-form .md-area, + .ide-commit-message-field { + background-color: $highlight-background; + } + + .multi-file-commit-panel { + padding-right: 0; + } + + .ide-mode-tabs, + .multi-file-commit-panel-inner, + .multi-file-commit-panel-inner-content, + .multi-file-commit-form, + .multi-file-edit-pane, + .ide-right-sidebar .ide-activity-bar, + .ide-sidebar-link.active, + .multi-file-tabs li.active, + .multi-file-tabs li, + .ide-status-bar, + .ide-commit-editor-header, + .ide-file-templates { + border-color: $border-color; + } + + .multi-file-commit-form > .commit-form-compact, + .ide-tree-header, + .multi-file-commit-panel-header, + .multi-file-commit-form > form, + .multi-file-commit-form hr, + .ide-commit-list-container.is-first, + .multi-file-commit-form .nav-links:not(.quick-links) { + border-color: $background; + } + + .multi-file-tabs li.active { + border-bottom-color: $highlight-background; + } + + .multi-file-tabs, + .ide-commit-editor-header { + box-shadow: inset 0 -1px $border-color; + } + + .ide-sidebar-link.active { + color: $highlight-accent; + box-shadow: inset 3px 0 $highlight-accent; + + &.is-right { + box-shadow: inset -3px 0 $highlight-accent; + } + } + + .nav-links li.active a { + border-color: $highlight-accent; + color: $text-color; + } + + .avatar-container { + &, + .avatar { + color: $text-color; + background-color: $highlight-background; + border-color: $highlight-background; + } + } + + .ide-status-bar { + background-color: $footer-background; + } + + input[type='text'] { + border-color: $input-border; + background: $input-background; + } + + input[type='text'], + textarea { + color: $input-color !important; + } + + .ide-entry-dropdown-toggle:hover { + background: $gray-800; + } + + .btn:hover { + border-width: 2px; + padding: 5px 9px; + } + + .btn.btn-sm:hover { + padding: 3px 9px; + } + + .btn.btn-block:hover { + padding: 5px 0; + } + + .btn-inverted, + .btn-default, + .dropdown, + .dropdown-menu-toggle { + background-color: $input-background !important; + color: $input-color !important; + border-color: $btn-default-border; + } + + .btn-inverted, + .btn-default { + &:hover, + &:focus { + border-color: $btn-default-hover-border !important; + } + } + + .dropdown, + .dropdown-menu-toggle { + &:hover, + &:focus { + background-color: $gray-900 !important; + border-color: $gray-200 !important; + } + } + + .btn-primary { + background-color: $btn-primary-background; + border-color: $btn-primary-border !important; + + &:hover, + &:focus { + border-color: $btn-primary-hover-border !important; + } + } + + .btn-success { + background-color: $btn-success-background; + border-color: $btn-success-border !important; + + &:hover, + &:focus { + border-color: $btn-success-hover-border !important; + } + } + + .btn[disabled] { + background: $btn-default-background !important; + border: 1px solid $btn-disabled-border !important; + color: $btn-disabled-color !important; + } + + .md-previewer, + .ide-empty-state { + background-color: $border-color; + } + + .ide-tree-header svg:focus, + .ide-tree-header svg:hover { + color: $blue-600; + } + + .animation-container { + [class^='skeleton-line-'] { + background-color: $gray-800; + + &::after { + background-image: linear-gradient(to right, + $gray-800 0%, + $gray-700 20%, + $gray-800 40%, + $gray-800 100%); + } + } + } +} + +.navbar.theme-dark { + border-bottom-color: transparent; +} diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index b1d79a41ba7..0292919ea50 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -105,10 +105,6 @@ } } - .js-ca-dropdown { - top: $gl-padding-top; - } - .stage-panel-body { display: flex; flex-wrap: wrap; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 8b51ba7ae62..c60e3c6b2b1 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -588,7 +588,8 @@ $note-form-margin-left: 72px; a { color: inherit; - &:hover { + &:hover, + &.hover { color: $blue-600; } @@ -605,6 +606,21 @@ $note-form-margin-left: 72px; .author-link { color: $gl-text-color; } + + // Prevent flickering of link when hovering between `author-name-link` and `.author-username-link` + .author-name-link + .author-username .author-username-link { + position: relative; + + &::before { + content: ''; + position: absolute; + right: 100%; + width: 0.25rem; + height: 100%; + top: 0; + bottom: 0; + } + } } .discussion-header { diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index af0afa9cc3b..f61245bed24 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -64,6 +64,12 @@ padding: $gl-padding-8 $gl-padding-12; } } + + .show-last-dropdown { + // same as in .dropdown-menu-toggle + // see app/assets/stylesheets/framework/dropdowns.scss + width: 160px; + } } .prometheus-panel { diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 2a811e08fd3..3fcf9a74cb2 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -85,8 +85,14 @@ .gl-bg-blue-50 { @include gl-bg-blue-50; } .gl-bg-red-100 { @include gl-bg-red-100; } .gl-bg-orange-100 { @include gl-bg-orange-100; } +.gl-bg-gray-50 { @include gl-bg-gray-50; } .gl-bg-gray-100 { @include gl-bg-gray-100; } .gl-bg-green-100 { @include gl-bg-green-100;} +.gl-bg-blue-500 { @include gl-bg-blue-500; } +.gl-bg-green-500 { @include gl-bg-green-500; } +.gl-bg-theme-indigo-500 { @include gl-bg-theme-indigo-500; } +.gl-bg-red-500 { @include gl-bg-red-500; } +.gl-bg-orange-500 { @include gl-bg-orange-500; } .gl-text-blue-500 { @include gl-text-blue-500; } .gl-text-gray-500 { @include gl-text-gray-500; } @@ -102,8 +108,14 @@ .gl-text-green-700 { @include gl-text-green-700; } .gl-align-items-center { @include gl-align-items-center; } + .d-sm-table-column { @include media-breakpoint-up(sm) { display: table-column !important; } } + +.gl-white-space-normal { @include gl-white-space-normal; } +.gl-word-break-all { @include gl-word-break-all; } +.gl-line-height-inherit { line-height: inherit; } +.gl-text-align-inherit { text-align: inherit; } diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 9eaa55039c8..4639d8adfe0 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -61,7 +61,15 @@ class Admin::RunnersController < Admin::ApplicationController end def runner_params - params.require(:runner).permit(Ci::Runner::FORM_EDITABLE) + params.require(:runner).permit(permitted_attrs) + end + + def permitted_attrs + if Gitlab.com? + Ci::Runner::FORM_EDITABLE + Ci::Runner::MINUTES_COST_FACTOR_FIELDS + else + Ci::Runner::FORM_EDITABLE + end end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b2496427924..26ef6117e1c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -150,7 +150,7 @@ class ApplicationController < ActionController::Base payload[:username] = logged_user.try(:username) end - payload[:queue_duration] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] + payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY] end ## diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index 825181568ad..d486d734db8 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -16,7 +16,7 @@ module EnforcesTwoFactorAuthentication end def check_two_factor_requirement - if two_factor_authentication_required? && current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor? + if two_factor_authentication_required? && current_user_requires_two_factor? redirect_to profile_two_factor_auth_path end end @@ -27,6 +27,10 @@ module EnforcesTwoFactorAuthentication current_user.try(:ultraauth_user?) end + def current_user_requires_two_factor? + current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor? + end + # rubocop: disable CodeReuse/ActiveRecord def two_factor_authentication_reason(global: -> {}, group: -> {}) if two_factor_authentication_required? @@ -61,3 +65,5 @@ module EnforcesTwoFactorAuthentication session[:skip_two_factor] && session[:skip_two_factor] > Time.current end end + +EnforcesTwoFactorAuthentication.prepend_if_ee('EE::EnforcesTwoFactorAuthentication') diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb index 4c998055a5d..ff283f9bb62 100644 --- a/app/controllers/concerns/integrations_actions.rb +++ b/app/controllers/concerns/integrations_actions.rb @@ -15,9 +15,7 @@ module IntegrationsActions end def update - integration.attributes = service_params[:service] - - saved = integration.save(context: :manual_change) + saved = integration.update(service_params[:service]) respond_to do |format| format.html do diff --git a/app/controllers/concerns/members_presentation.rb b/app/controllers/concerns/members_presentation.rb index 0a9d3d86245..ceccef8113f 100644 --- a/app/controllers/concerns/members_presentation.rb +++ b/app/controllers/concerns/members_presentation.rb @@ -5,6 +5,7 @@ module MembersPresentation def present_members(members) preload_associations(members) + Gitlab::View::Presenter::Factory.new( members, current_user: current_user, diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 3ccf227c431..e2c83f9a069 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -19,6 +19,7 @@ module ServiceParams :color, :colorize_messages, :comment_on_event_enabled, + :comment_detail, :confidential_issues_events, :default_irc_uri, :description, diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index 46ba270f328..50c93441dd4 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -82,6 +82,6 @@ module SpammableActions return false if spammable.errors.count > 1 # re-render "new" template in case there are other errors return false unless Gitlab::Recaptcha.enabled? - spammable.spam + spammable.needs_recaptcha? end end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 039991e07a2..25c48fadf49 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -61,31 +61,35 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end end - # rubocop: disable CodeReuse/ActiveRecord def load_projects(finder_params) @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute finder_params[:use_cte] = true if use_cte_for_finder? - projects = ProjectsFinder - .new(params: finder_params, current_user: current_user) - .execute - .includes(:route, :creator, :group, namespace: [:route, :owner]) - .preload(:project_feature) - .page(finder_params[:page]) + projects = ProjectsFinder.new(params: finder_params, current_user: current_user).execute + + projects = preload_associations(projects) + projects = projects.page(finder_params[:page]) prepare_projects_for_rendering(projects) end + + # rubocop: disable CodeReuse/ActiveRecord + def preload_associations(projects) + projects.includes(:route, :creator, :group, namespace: [:route, :owner]).preload(:project_feature) + end # rubocop: enable CodeReuse/ActiveRecord def use_cte_for_finder? # The starred action loads public projects, which causes the CTE to be less efficient - action_name == 'index' && Feature.enabled?(:use_cte_for_projects_finder, default_enabled: true) + action_name == 'index' end def load_events - projects = load_projects(params.merge(non_public: true)) + projects = ProjectsFinder + .new(params: params.merge(non_public: true), current_user: current_user) + .execute @events = EventCollection .new(projects, offset: params[:offset].to_i, filter: event_filter) diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index a8a76b47bbe..705a586d614 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -66,18 +66,21 @@ class Explore::ProjectsController < Explore::ApplicationController @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute end - # rubocop: disable CodeReuse/ActiveRecord def load_projects load_project_counts - projects = ProjectsFinder.new(current_user: current_user, params: params) - .execute - .includes(:route, :creator, :group, namespace: [:route, :owner]) - .page(params[:page]) - .without_count + projects = ProjectsFinder.new(current_user: current_user, params: params).execute + + projects = preload_associations(projects) + projects = projects.page(params[:page]).without_count prepare_projects_for_rendering(projects) end + + # rubocop: disable CodeReuse/ActiveRecord + def preload_associations(projects) + projects.includes(:route, :creator, :group, namespace: [:route, :owner]) + end # rubocop: enable CodeReuse/ActiveRecord def set_sorting @@ -110,3 +113,5 @@ class Explore::ProjectsController < Explore::ApplicationController end end end + +Explore::ProjectsController.prepend_if_ee('EE::Explore::ProjectsController') diff --git a/app/controllers/groups/deploy_tokens_controller.rb b/app/controllers/groups/deploy_tokens_controller.rb index a765922fc54..6bb075fd115 100644 --- a/app/controllers/groups/deploy_tokens_controller.rb +++ b/app/controllers/groups/deploy_tokens_controller.rb @@ -7,6 +7,6 @@ class Groups::DeployTokensController < Groups::ApplicationController @token = @group.deploy_tokens.find(params[:id]) @token.revoke! - redirect_to group_settings_ci_cd_path(@group, anchor: 'js-deploy-tokens') + redirect_to group_settings_repository_path(@group, anchor: 'js-deploy-tokens') end end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 664c58e8b7a..63311ab983b 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -21,19 +21,26 @@ class Groups::GroupMembersController < Groups::ApplicationController def index @sort = params[:sort].presence || sort_value_name + @project = @group.projects.find(params[:project_id]) if params[:project_id] - @members = find_members + + @members = GroupMembersFinder + .new(@group, current_user, params: filter_params) + .execute(include_relations: requested_relations) if can_manage_members @skip_groups = @group.related_group_ids - @invited_members = present_invited_members(@members) + + @invited_members = @members.invite + @invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present? + @invited_members = present_invited_members(@invited_members) end - @members = @members.non_invite - @members = present_group_members(@members) + @members = present_group_members(@members.non_invite) @requesters = present_members( - AccessRequestsFinder.new(@group).execute(current_user)) + AccessRequestsFinder.new(@group).execute(current_user) + ) @group_member = @group.group_members.new end @@ -43,30 +50,24 @@ class Groups::GroupMembersController < Groups::ApplicationController private - def present_invited_members(members) - invited_members = members.invite - - if params[:search_invited].present? - invited_members = invited_members.search_invite_email(params[:search_invited]) - end - - present_members(invited_members - .page(params[:invited_members_page]) - .per(MEMBER_PER_PAGE_LIMIT)) + def can_manage_members + can?(current_user, :admin_group_member, @group) end - def find_members - filter_params = params.slice(:two_factor, :search).merge(sort: @sort) - GroupMembersFinder.new(@group, current_user, params: filter_params).execute(include_relations: requested_relations) + def present_invited_members(invited_members) + present_members(invited_members + .page(params[:invited_members_page]) + .per(MEMBER_PER_PAGE_LIMIT)) end - def can_manage_members - can?(current_user, :admin_group_member, @group) + def present_group_members(members) + present_members(members + .page(params[:page]) + .per(MEMBER_PER_PAGE_LIMIT)) end - def present_group_members(original_members) - members = original_members.page(params[:page]).per(MEMBER_PER_PAGE_LIMIT) - present_members(members) + def filter_params + params.permit(:two_factor, :search).merge(sort: @sort) end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index a478e9fffb8..8cfbd293597 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -5,6 +5,9 @@ class Groups::MilestonesController < Groups::ApplicationController before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels, :destroy] before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy] + before_action do + push_frontend_feature_flag(:burnup_charts) + end def index respond_to do |format| diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index 6b842fc9fe1..18f336eae78 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -7,10 +7,9 @@ module Groups before_action :authorize_admin_group! before_action :authorize_update_max_artifacts_size!, only: [:update] before_action do - push_frontend_feature_flag(:new_variables_ui, @group) - push_frontend_feature_flag(:ajax_new_deploy_token, @group) + push_frontend_feature_flag(:new_variables_ui, @group, default_enabled: true) end - before_action :define_variables, only: [:show, :create_deploy_token] + before_action :define_variables, only: [:show] def show end @@ -42,38 +41,10 @@ module Groups redirect_to group_settings_ci_cd_path end - def create_deploy_token - result = Projects::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute - @new_deploy_token = result[:deploy_token] - - if result[:status] == :success - respond_to do |format| - format.json do - # IMPORTANT: It's a security risk to expose the token value more than just once here! - json = API::Entities::DeployTokenWithToken.represent(@new_deploy_token).as_json - render json: json, status: result[:http_status] - end - format.html do - flash.now[:notice] = s_('DeployTokens|Your new group deploy token has been created.') - render :show - end - end - else - respond_to do |format| - format.json { render json: { message: result[:message] }, status: result[:http_status] } - format.html do - flash.now[:alert] = result[:message] - render :show - end - end - end - end - private def define_variables define_ci_variables - define_deploy_token_variables end def define_ci_variables @@ -83,12 +54,6 @@ module Groups .map { |variable| variable.present(current_user: current_user) } end - def define_deploy_token_variables - @deploy_tokens = @group.deploy_tokens.active - - @new_deploy_token = DeployToken.new - end - def authorize_admin_group! return render_404 unless can?(current_user, :admin_group, group) end @@ -112,10 +77,6 @@ module Groups def update_group_params params.require(:group).permit(:max_artifacts_size) end - - def deploy_token_params - params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :username) - end end end end diff --git a/app/controllers/groups/settings/repository_controller.rb b/app/controllers/groups/settings/repository_controller.rb new file mode 100644 index 00000000000..6e8c5628d24 --- /dev/null +++ b/app/controllers/groups/settings/repository_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Groups + module Settings + class RepositoryController < Groups::ApplicationController + skip_cross_project_access_check :show + before_action :authorize_admin_group! + before_action :define_deploy_token_variables + before_action do + push_frontend_feature_flag(:ajax_new_deploy_token, @group) + end + + def create_deploy_token + result = Groups::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute + @new_deploy_token = result[:deploy_token] + + if result[:status] == :success + respond_to do |format| + format.json do + # IMPORTANT: It's a security risk to expose the token value more than just once here! + json = API::Entities::DeployTokenWithToken.represent(@new_deploy_token).as_json + render json: json, status: result[:http_status] + end + format.html do + flash.now[:notice] = s_('DeployTokens|Your new group deploy token has been created.') + render :show + end + end + else + respond_to do |format| + format.json { render json: { message: result[:message] }, status: result[:http_status] } + format.html do + flash.now[:alert] = result[:message] + render :show + end + end + end + end + + private + + def define_deploy_token_variables + @deploy_tokens = @group.deploy_tokens.active + + @new_deploy_token = DeployToken.new + end + + def deploy_token_params + params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :write_registry, :username) + end + end + end +end diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb new file mode 100644 index 00000000000..46db87dba94 --- /dev/null +++ b/app/controllers/projects/alert_management_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Projects::AlertManagementController < Projects::ApplicationController + def index + respond_to do |format| + format.html + end + end +end diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 248b75d16ed..ebc81976529 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -13,16 +13,13 @@ class Projects::ForksController < Projects::ApplicationController before_action :authorize_fork_project!, only: [:new, :create] before_action :authorize_fork_namespace!, only: [:create] - # rubocop: disable CodeReuse/ActiveRecord def index @total_forks_count = project.forks.size @public_forks_count = project.forks.public_only.size @private_forks_count = @total_forks_count - project.forks.public_and_internal_only.size @internal_forks_count = @total_forks_count - @public_forks_count - @private_forks_count - @forks = ForkProjectsFinder.new(project, params: params.merge(search: params[:filter_projects]), current_user: current_user).execute - @forks = @forks.includes(:route, :creator, :group, namespace: [:route, :owner]) - .page(params[:page]) + @forks = load_forks.page(params[:page]) prepare_projects_for_rendering(@forks) @@ -36,7 +33,6 @@ class Projects::ForksController < Projects::ApplicationController end end end - # rubocop: enable CodeReuse/ActiveRecord def new @namespaces = fork_service.valid_fork_targets - [project.namespace] @@ -59,10 +55,19 @@ class Projects::ForksController < Projects::ApplicationController redirect_to project_path(@forked_project), notice: "The project '#{@forked_project.name}' was successfully forked." end end - # rubocop: enable CodeReuse/ActiveRecord private + def load_forks + forks = ForkProjectsFinder.new( + project, + params: params.merge(search: params[:filter_projects]), + current_user: current_user + ).execute + + forks.includes(:route, :creator, :group, namespace: [:route, :owner]) + end + def fork_service strong_memoize(:fork_service) do ::Projects::ForkService.new(project, current_user, namespace: fork_namespace) @@ -83,3 +88,5 @@ class Projects::ForksController < Projects::ApplicationController Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42335') end end + +Projects::ForksController.prepend_if_ee('EE::Projects::ForksController') diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb index 26d9b4b223f..711e23dc3ce 100644 --- a/app/controllers/projects/import/jira_controller.rb +++ b/app/controllers/projects/import/jira_controller.rb @@ -11,11 +11,10 @@ module Projects before_action :authorize_admin_project!, only: [:import] def show - @is_jira_configured = @project.jira_service.present? - return if Feature.enabled?(:jira_issue_import_vue, @project) + jira_service = @project.jira_service - if !@project.latest_jira_import&.in_progress? && current_user&.can?(:admin_project, @project) - jira_client = @project.jira_service.client + if jira_service.present? && !@project.latest_jira_import&.in_progress? && current_user&.can?(:admin_project, @project) + jira_client = jira_service.client jira_projects = jira_client.Project.all if jira_projects.present? @@ -25,7 +24,9 @@ module Projects end end - flash[:notice] = _("Import %{status}") % { status: @project.jira_import_status } unless @project.latest_jira_import&.initial? + unless Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true) + flash[:notice] = _("Import %{status}") % { status: @project.jira_import_status } unless @project.latest_jira_import&.initial? + end end def import @@ -35,7 +36,7 @@ module Projects response = ::JiraImport::StartImportService.new(current_user, @project, jira_project_key).execute flash[:notice] = response.message if response.message.present? else - flash[:alert] = 'No jira project key has been provided.' + flash[:alert] = 'No Jira project key has been provided.' end redirect_to project_import_jira_path(@project) @@ -50,7 +51,7 @@ module Projects end def jira_integration_configured? - return if Feature.enabled?(:jira_issue_import_vue, @project) + return if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true) return if @project.jira_service flash[:notice] = _("Configure the Jira integration first on your project's %{strong_start} Settings > Integrations > Jira%{strong_end} page." % diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 51ad8edb012..3aae8990f07 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController include RecordUserLastActivity def issue_except_actions - %i[index calendar new create bulk_update import_csv] + %i[index calendar new create bulk_update import_csv export_csv] end def set_issuables_index_only_actions @@ -20,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) } - prepend_before_action :authenticate_user!, only: [:new] + prepend_before_action :authenticate_user!, only: [:new, :export_csv] # designs is only applicable to EE, but defining a prepend_before_action in EE code would overwrite this prepend_before_action :store_uri, only: [:new, :show, :designs] @@ -189,6 +189,13 @@ class Projects::IssuesController < Projects::ApplicationController end end + def export_csv + ExportCsvWorker.perform_async(current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker + + index_path = project_issues_path(project) + redirect_to(index_path, notice: "Your CSV export has started. It will be emailed to #{current_user.notification_email} when complete.") + end + def import_csv if uploader = UploadService.new(project, params[:file]).execute ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 89de40006ff..b2bb49993d1 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -14,7 +14,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo skip_before_action :merge_request, only: [:index, :bulk_update] before_action :whitelist_query_limiting, only: [:assign_related_issues, :update] before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] - before_action :authorize_read_actual_head_pipeline!, only: [:test_reports, :exposed_artifacts, :coverage_reports] + before_action :authorize_read_actual_head_pipeline!, only: [:test_reports, :exposed_artifacts, :coverage_reports, :terraform_reports] before_action :set_issuables_index, only: [:index] before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] @@ -25,6 +25,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline) push_frontend_feature_flag(:code_navigation, @project) push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true) + push_frontend_feature_flag(:merge_ref_head_comments, @project) end before_action do @@ -142,6 +143,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end + def terraform_reports + reports_response(@merge_request.find_terraform_reports) + end + def exposed_artifacts if @merge_request.has_exposed_artifacts? reports_response(@merge_request.find_exposed_artifacts) @@ -339,11 +344,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def serialize_widget(merge_request) - serializer.represent(merge_request, serializer: 'widget') + cached_data = serializer.represent(merge_request, serializer: 'poll_cached_widget') + widget_data = serializer.represent(merge_request, serializer: 'poll_widget') + cached_data.merge!(widget_data) end def serializer - MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) + @serializer ||= MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) end def define_edit_vars diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index d301a5be391..56f1f1a1019 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -6,6 +6,9 @@ class Projects::MilestonesController < Projects::ApplicationController before_action :check_issuables_available! before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote] + before_action do + push_frontend_feature_flag(:burnup_charts) + end # Allow read any milestone before_action :authorize_read_milestone! diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 726ce8974c7..bb0381ba19d 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -12,6 +12,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do push_frontend_feature_flag(:junit_pipeline_view) + push_frontend_feature_flag(:filter_pipelines_search) end before_action :ensure_pipeline, only: [:show] @@ -169,19 +170,9 @@ class Projects::PipelinesController < Projects::ApplicationController end format.json do - if pipeline_test_report == :error - render json: { status: :error_parsing_report } - else - test_reports = if params[:scope] == "with_attachment" - pipeline_test_report.with_attachment! - else - pipeline_test_report - end - - render json: TestReportSerializer - .new(current_user: @current_user) - .represent(test_reports, project: project) - end + render json: TestReportSerializer + .new(current_user: @current_user) + .represent(pipeline_test_report, project: project) end end end @@ -189,11 +180,7 @@ class Projects::PipelinesController < Projects::ApplicationController def test_reports_count return unless Feature.enabled?(:junit_pipeline_view, project) - begin - render json: { total_count: pipeline.test_reports_count }.to_json - rescue Gitlab::Ci::Parsers::ParserError - render json: { total_count: 0 }.to_json - end + render json: { total_count: pipeline.test_reports_count }.to_json end private @@ -269,9 +256,9 @@ class Projects::PipelinesController < Projects::ApplicationController def pipeline_test_report strong_memoize(:pipeline_test_report) do - @pipeline.test_reports - rescue Gitlab::Ci::Parsers::ParserError - :error + @pipeline.test_reports.tap do |reports| + reports.with_attachment! if params[:scope] == 'with_attachment' + end end end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 109c8b7005f..3e52248f292 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -17,8 +17,9 @@ class Projects::ProjectMembersController < Projects::ApplicationController @group_links = @project.project_group_links @group_links = @group_links.search(params[:search]) if params[:search].present? - @project_members = MembersFinder.new(@project, current_user) - .execute(include_relations: requested_relations, params: params.merge(sort: @sort)) + @project_members = MembersFinder + .new(@project, current_user, params: filter_params) + .execute(include_relations: requested_relations) @project_members = present_members(@project_members.page(params[:page])) @@ -43,12 +44,17 @@ class Projects::ProjectMembersController < Projects::ApplicationController return render_404 end - redirect_to(project_project_members_path(project), - notice: notice) + redirect_to(project_project_members_path(project), notice: notice) end # MembershipActions concern alias_method :membershipable, :project + + private + + def filter_params + params.permit(:search).merge(sort: @sort) + end end Projects::ProjectMembersController.prepend_if_ee('EE::Projects::ProjectMembersController') diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index a0f98d8f1d2..c7cd9649dac 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -6,8 +6,9 @@ module Projects before_action :authorize_admin_pipeline! before_action :define_variables before_action do - push_frontend_feature_flag(:new_variables_ui, @project) + push_frontend_feature_flag(:new_variables_ui, @project, default_enabled: true) push_frontend_feature_flag(:ajax_new_deploy_token, @project) + push_frontend_feature_flag(:ci_key_autocomplete, default_enabled: true) end def show diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 045aa38230c..bb20ea1de49 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -36,6 +36,10 @@ class ProjectsController < Projects::ApplicationController layout :determine_layout + before_action do + push_frontend_feature_flag(:metrics_dashboard_visibility_switching_available) + end + def index redirect_to(current_user ? root_path : explore_root_path) end diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index 9e134ba9526..118036de230 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -23,7 +23,7 @@ module Repositories # POST /foo/bar.git/git-upload-pack (git pull) def git_upload_pack - enqueue_fetch_statistics_update + update_fetch_statistics render_ok end @@ -76,12 +76,16 @@ module Repositories render plain: exception.message, status: :service_unavailable end - def enqueue_fetch_statistics_update + def update_fetch_statistics + return unless project return if Gitlab::Database.read_only? return unless repo_type.project? - return unless project&.daily_statistics_enabled? - ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker + if Feature.enabled?(:project_statistics_sync, project, default_enabled: true) + Projects::FetchStatisticsIncrementService.new(project).execute + else + ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker + end end def access diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 06374736dcf..5ee97885071 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -128,6 +128,10 @@ class UsersController < ApplicationController @user ||= find_routable!(User, params[:username]) end + def personal_projects + PersonalProjectsFinder.new(user).execute(current_user) + end + def contributed_projects ContributedProjectsFinder.new(user).execute(current_user) end @@ -147,8 +151,7 @@ class UsersController < ApplicationController end def load_projects - @projects = - PersonalProjectsFinder.new(user).execute(current_user) + @projects = personal_projects .page(params[:page]) .per(params[:limit]) diff --git a/app/finders/autocomplete/move_to_project_finder.rb b/app/finders/autocomplete/move_to_project_finder.rb index af6defc1fc6..f1c1eacafe6 100644 --- a/app/finders/autocomplete/move_to_project_finder.rb +++ b/app/finders/autocomplete/move_to_project_finder.rb @@ -28,7 +28,8 @@ module Autocomplete .optionally_search(search, include_namespace: true) .excluding_project(project_id) .eager_load_namespace_and_owner - .sorted_by_name_asc_limited(LIMIT) + .sorted_by_stars_desc + .limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord end end end diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index a56d4ebb368..949af103eb3 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -9,7 +9,6 @@ class GroupMembersFinder < UnionFinder # search: string # created_after: datetime # created_before: datetime - attr_reader :params def initialize(group, user = nil, params: {}) @@ -22,7 +21,6 @@ class GroupMembersFinder < UnionFinder def execute(include_relations: [:inherited, :direct]) group_members = group.members relations = [] - @params = params return group_members if include_relations == [:direct] diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index 0617f34dc8c..e08ed737ca6 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -4,17 +4,19 @@ class MembersFinder # Params can be any of the following: # sort: string # search: string + attr_reader :params - def initialize(project, current_user) + def initialize(project, current_user, params: {}) @project = project - @current_user = current_user @group = project.group + @current_user = current_user + @params = params end - def execute(include_relations: [:inherited, :direct], params: {}) - members = find_members(include_relations, params) + def execute(include_relations: [:inherited, :direct]) + members = find_members(include_relations) - filter_members(members, params) + filter_members(members) end def can?(*args) @@ -25,7 +27,7 @@ class MembersFinder attr_reader :project, :current_user, :group - def find_members(include_relations, params) + def find_members(include_relations) project_members = project.project_members project_members = project_members.non_invite unless can?(current_user, :admin_project, project) @@ -39,7 +41,7 @@ class MembersFinder distinct_union_of_members(union_members) end - def filter_members(members, params) + def filter_members(members) members = members.search(params[:search]) if params[:search].present? members = members.sort_by_attribute(params[:sort]) if params[:sort].present? members diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb new file mode 100644 index 00000000000..f8d62ba86af --- /dev/null +++ b/app/graphql/resolvers/board_lists_resolver.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Resolvers + class BoardListsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::BoardListType, null: true + + alias_method :board, :object + + def resolve(lookahead: nil) + authorize!(board) + + lists = board_lists + + if load_preferences?(lookahead) + List.preload_preferences_for_user(lists, context[:current_user]) + end + + Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(lists) + end + + private + + def board_lists + service = Boards::Lists::ListService.new(board.resource_parent, context[:current_user]) + service.execute(board, create_default_lists: false) + end + + def authorized_resource?(board) + Ability.allowed?(context[:current_user], :read_list, board) + end + + def load_preferences?(lookahead) + lookahead&.selection(:edges)&.selection(:node)&.selects?(:collapsed) + end + end +end diff --git a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb new file mode 100644 index 00000000000..068323a3073 --- /dev/null +++ b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Resolvers + module Metrics + module Dashboards + class AnnotationResolver < Resolvers::BaseResolver + argument :from, Types::TimeType, + required: true, + description: "Timestamp marking date and time from which annotations need to be fetched" + + argument :to, Types::TimeType, + required: false, + description: "Timestamp marking date and time to which annotations need to be fetched" + + type Types::Metrics::Dashboards::AnnotationType, null: true + + alias_method :dashboard, :object + + def resolve(**args) + return [] unless dashboard + return [] unless Feature.enabled?(:metrics_dashboard_annotations, dashboard.environment&.project) + + ::Metrics::Dashboards::AnnotationsFinder.new(dashboard: dashboard, params: args).execute + end + end + end + end +end diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb new file mode 100644 index 00000000000..e94ff898807 --- /dev/null +++ b/app/graphql/types/board_list_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class BoardListType < BaseObject + graphql_name 'BoardList' + description 'Represents a list for an issue board' + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID (global ID) of the list' + field :title, GraphQL::STRING_TYPE, null: false, + description: 'Title of the list' + field :list_type, GraphQL::STRING_TYPE, null: false, + description: 'Type of the list' + field :position, GraphQL::INT_TYPE, null: true, + description: 'Position of list within the board' + field :label, Types::LabelType, null: true, + description: 'Label of the list' + field :collapsed, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if list is collapsed for this user', + resolve: -> (list, _args, ctx) { list.collapsed?(ctx[:current_user]) } + end + # rubocop: enable Graphql/AuthorizeTypes +end + +Types::BoardListType.prepend_if_ee('::EE::Types::BoardListType') diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb index 9c95a987fe4..c0be782ed1e 100644 --- a/app/graphql/types/board_type.rb +++ b/app/graphql/types/board_type.rb @@ -11,6 +11,13 @@ module Types description: 'ID (global ID) of the board' field :name, type: GraphQL::STRING_TYPE, null: true, description: 'Name of the board' + + field :lists, + Types::BoardListType.connection_type, + null: true, + description: 'Lists of the project board', + resolver: Resolvers::BoardListsResolver, + extras: [:lookahead] end end diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb index 11e834013ca..e7d09866bb5 100644 --- a/app/graphql/types/metrics/dashboard_type.rb +++ b/app/graphql/types/metrics/dashboard_type.rb @@ -9,6 +9,11 @@ module Types field :path, GraphQL::STRING_TYPE, null: true, description: 'Path to a file with the dashboard definition' + + field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true, + description: 'Annotations added to the dashboard. Will always return `null` ' \ + 'if `metrics_dashboard_annotations` feature flag is disabled', + resolver: Resolvers::Metrics::Dashboards::AnnotationResolver end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb new file mode 100644 index 00000000000..055d2544eff --- /dev/null +++ b/app/graphql/types/metrics/dashboards/annotation_type.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + module Metrics + module Dashboards + class AnnotationType < ::Types::BaseObject + authorize :read_metrics_dashboard_annotation + graphql_name 'MetricsDashboardAnnotation' + + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the annotation' + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the annotation' + + field :panel_id, GraphQL::STRING_TYPE, null: true, + description: 'ID of a dashboard panel to which the annotation should be scoped' + + field :starting_at, GraphQL::STRING_TYPE, null: true, + description: 'Timestamp marking start of annotated time span' + + field :ending_at, GraphQL::STRING_TYPE, null: true, + description: 'Timestamp marking end of annotated time span' + + def panel_id + object.panel_xid + end + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 3115a53e053..8356e763be9 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -26,7 +26,7 @@ module Types markdown_field :description_html, null: true field :tag_list, GraphQL::STRING_TYPE, null: true, - description: 'List of project tags' + description: 'List of project topics (not Git tags)' field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true, description: 'URL to connect to the project via SSH' diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index e530641d6ae..5cee0c2cf8f 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -10,6 +10,8 @@ module Types expose_permissions Types::PermissionTypes::User + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the user' field :name, GraphQL::STRING_TYPE, null: false, description: 'Human-readable name of the user' field :username, GraphQL::STRING_TYPE, null: false, diff --git a/app/helpers/analytics/navbar_helper.rb b/app/helpers/analytics/navbar_helper.rb new file mode 100644 index 00000000000..ddf2655c887 --- /dev/null +++ b/app/helpers/analytics/navbar_helper.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Analytics + module NavbarHelper + class NavbarSubItem + attr_reader :title, :path, :link, :link_to_options + + def initialize(title:, path:, link:, link_to_options: {}) + @title = title + @path = path + @link = link + @link_to_options = link_to_options.merge(title: title) + end + end + + def project_analytics_navbar_links(project, current_user) + [ + cycle_analytics_navbar_link(project, current_user), + repository_analytics_navbar_link(project, current_user), + ci_cd_analytics_navbar_link(project, current_user) + ].compact + end + + def group_analytics_navbar_links(group, current_user) + [] + end + + private + + def navbar_sub_item(args) + NavbarSubItem.new(args) + end + + def cycle_analytics_navbar_link(project, current_user) + return unless project_nav_tab?(:cycle_analytics) + + navbar_sub_item( + title: _('Value Stream'), + path: 'cycle_analytics#show', + link: project_cycle_analytics_path(project), + link_to_options: { class: 'shortcuts-project-cycle-analytics' } + ) + end + + def repository_analytics_navbar_link(project, current_user) + return if project.empty_repo? + + navbar_sub_item( + title: _('Repository'), + path: 'graphs#charts', + link: charts_project_graph_path(project, current_ref), + link_to_options: { class: 'shortcuts-repository-charts' } + ) + end + + def ci_cd_analytics_navbar_link(project, current_user) + return unless project_nav_tab?(:pipelines) + return unless project.feature_available?(:builds, current_user) || !project.empty_repo? + + navbar_sub_item( + title: _('CI / CD'), + path: 'pipelines#charts', + link: charts_project_pipelines_path(project) + ) + end + end +end + +Analytics::NavbarHelper.prepend_if_ee('EE::Analytics::NavbarHelper') diff --git a/app/helpers/analytics_navbar_helper.rb b/app/helpers/analytics_navbar_helper.rb deleted file mode 100644 index f94119c4eef..00000000000 --- a/app/helpers/analytics_navbar_helper.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -module AnalyticsNavbarHelper - class NavbarSubItem - attr_reader :title, :path, :link, :link_to_options - - def initialize(title:, path:, link:, link_to_options: {}) - @title = title - @path = path - @link = link - @link_to_options = link_to_options.merge(title: title) - end - end - - def project_analytics_navbar_links(project, current_user) - [ - cycle_analytics_navbar_link(project, current_user), - repository_analytics_navbar_link(project, current_user), - ci_cd_analytics_navbar_link(project, current_user) - ].compact - end - - def group_analytics_navbar_links(group, current_user) - [] - end - - private - - def navbar_sub_item(args) - NavbarSubItem.new(args) - end - - def cycle_analytics_navbar_link(project, current_user) - return unless project_nav_tab?(:cycle_analytics) - - navbar_sub_item( - title: _('Value Stream'), - path: 'cycle_analytics#show', - link: project_cycle_analytics_path(project), - link_to_options: { class: 'shortcuts-project-cycle-analytics' } - ) - end - - def repository_analytics_navbar_link(project, current_user) - return if project.empty_repo? - - navbar_sub_item( - title: _('Repository'), - path: 'graphs#charts', - link: charts_project_graph_path(project, current_ref), - link_to_options: { class: 'shortcuts-repository-charts' } - ) - end - - def ci_cd_analytics_navbar_link(project, current_user) - return unless project_nav_tab?(:pipelines) - return unless project.feature_available?(:builds, current_user) || !project.empty_repo? - - navbar_sub_item( - title: _('CI / CD'), - path: 'pipelines#charts', - link: charts_project_pipelines_path(project) - ) - end -end - -AnalyticsNavbarHelper.prepend_if_ee('EE::AnalyticsNavbarHelper') diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index e1aed5393ea..c999d1f94ad 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -93,8 +93,8 @@ module ButtonHelper content_tag (href ? :a : :span), (href ? button_content : title), class: "#{title.downcase}-selector #{active_class}", - href: (href if href), - data: (data if data) + href: href, + data: data end end diff --git a/app/helpers/ci_variables_helper.rb b/app/helpers/ci_variables_helper.rb index df220effd5d..cd0718c1b82 100644 --- a/app/helpers/ci_variables_helper.rb +++ b/app/helpers/ci_variables_helper.rb @@ -7,7 +7,7 @@ module CiVariablesHelper def create_deploy_token_path(entity, opts = {}) if entity.is_a?(Group) - create_deploy_token_group_settings_ci_cd_path(entity, opts) + create_deploy_token_group_settings_repository_path(entity, opts) else # TODO: change this path to 'create_deploy_token_project_settings_ci_cd_path' # See MR comment for more detail: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27059#note_311585356 diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index 52f189b122f..bd400009c96 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -25,7 +25,7 @@ module EnvironmentHelper def deployment_link(deployment, text: nil) return unless deployment - link_label = text ? text : "##{deployment.iid}" + link_label = text || "##{deployment.iid}" link_to link_label, deployment_path(deployment) end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 5b640ea6538..3368fc7aa86 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -46,7 +46,9 @@ module EnvironmentsHelper "environment-state" => "#{environment.state}", "custom-metrics-path" => project_prometheus_metrics_path(project), "validate-query-path" => validate_query_project_prometheus_metrics_path(project), - "custom-metrics-available" => "#{custom_metrics_available?(project)}" + "custom-metrics-available" => "#{custom_metrics_available?(project)}", + "alerts-endpoint" => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json), + "prometheus-alerts-available" => "#{can?(current_user, :read_prometheus_alerts, project)}" } end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 2cd685ddcd4..91f8bc33e3e 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -15,6 +15,7 @@ module GroupsHelper groups#projects groups#edit badges#index + repository#show ci_cd#show integrations#index integrations#edit diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 8a79217c929..070089d6ef8 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -9,19 +9,6 @@ module PreferencesHelper ] end - # Maps `dashboard` values to more user-friendly option text - DASHBOARD_CHOICES = { - projects: _("Your Projects (default)"), - stars: _("Starred Projects"), - project_activity: _("Your Projects' Activity"), - starred_project_activity: _("Starred Projects' Activity"), - groups: _("Your Groups"), - todos: _("Your To-Do List"), - issues: _("Assigned Issues"), - merge_requests: _("Assigned Merge Requests"), - operations: _("Operations Dashboard") - }.with_indifferent_access.freeze - # Returns an Array usable by a select field for more user-friendly option text def dashboard_choices dashboards = User.dashboards.keys @@ -31,10 +18,25 @@ module PreferencesHelper dashboards.map do |key| # Use `fetch` so `KeyError` gets raised when a key is missing - [DASHBOARD_CHOICES.fetch(key), key] + [localized_dashboard_choices.fetch(key), key] end end + # Maps `dashboard` values to more user-friendly option text + def localized_dashboard_choices + { + projects: _("Your Projects (default)"), + stars: _("Starred Projects"), + project_activity: _("Your Projects' Activity"), + starred_project_activity: _("Starred Projects' Activity"), + groups: _("Your Groups"), + todos: _("Your To-Do List"), + issues: _("Assigned Issues"), + merge_requests: _("Assigned Merge Requests"), + operations: _("Operations Dashboard") + }.with_indifferent_access.freeze + end + def project_view_choices [ ['Files and Readme (default)', :files], @@ -75,9 +77,9 @@ module PreferencesHelper # Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too def validate_dashboard_choices!(user_dashboards) - if user_dashboards.size != DASHBOARD_CHOICES.size + if user_dashboards.size != localized_dashboard_choices.size raise "`User` defines #{user_dashboards.size} dashboard choices," \ - " but `DASHBOARD_CHOICES` defined #{DASHBOARD_CHOICES.size}." + " but `localized_dashboard_choices` defined #{localized_dashboard_choices.size}." end end diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb new file mode 100644 index 00000000000..1b0400fbaa5 --- /dev/null +++ b/app/helpers/projects/alert_management_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Projects::AlertManagementHelper + def alert_management_data(project) + { + 'index-path' => project_alert_management_index_path(project, + format: :json), + 'enable-alert-management-path' => project_settings_operations_path(project), + 'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg') + } + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e700f0dbf2a..bd207615e7c 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -448,6 +448,7 @@ module ProjectsHelper clusters: :read_cluster, serverless: :read_cluster, error_tracking: :read_sentry_issue, + alert_management: :read_alert_management, labels: :read_label, issues: :read_issue, project_members: :read_project_member, @@ -707,6 +708,7 @@ module ProjectsHelper clusters functions error_tracking + alert_management user gcp logs @@ -737,3 +739,5 @@ module ProjectsHelper can?(current_user, :destroy_container_image, project) end end + +ProjectsHelper.prepend_if_ee('EE::ProjectsHelper') diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index a9f90a8f5e4..fd7e58826b5 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -160,14 +160,4 @@ module SnippetsHelper title: 'Download', rel: 'noopener noreferrer') end - - def snippet_file_name(snippet) - blob = if Feature.enabled?(:version_snippets, current_user) && !snippet.repository.empty? - snippet.blobs.first - else - snippet.blob - end - - blob.name - end end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 3fd865003c1..d4d93ab9795 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -91,6 +91,20 @@ module Emails end end + def issues_csv_email(user, project, csv_data, export_status) + @project = project + @issues_count = export_status.fetch(:rows_expected) + @written_count = export_status.fetch(:rows_written) + @truncated = export_status.fetch(:truncated) + + filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv" + attachments[filename] = { content: csv_data, mime_type: 'text/csv' } + mail(to: user.notification_email_for(@project.group), subject: subject("Exported issues")) do |format| + format.html { render layout: 'mailer' } + format.text { render layout: 'mailer' } + end + end + private def setup_issue_mail(issue_id, recipient_id, closed_via: nil) diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 114737eb232..38e1d9532a6 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -80,6 +80,10 @@ class NotifyPreview < ActionMailer::Preview Notify.import_issues_csv_email(user.id, project.id, { success: 3, errors: [5, 6, 7], valid_file: true }) end + def issues_csv_email + Notify.issues_csv_email(user, project, '1997,Ford,E350', { truncated: false, rows_expected: 3, rows_written: 3 }).message + end + def closed_merge_request_email Notify.closed_merge_request_email(user.id, issue.id, user.id).message end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index cbd7945a8b5..8c480969dd4 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -525,6 +525,7 @@ module Ci strong_memoize(:variables) do Gitlab::Ci::Variables::Collection.new .concat(persisted_variables) + .concat(job_jwt_variables) .concat(scoped_variables) .concat(job_variables) .concat(environment_changed_page_variables) @@ -877,6 +878,14 @@ module Ci coverage_report end + def collect_terraform_reports!(terraform_reports) + each_report(::Ci::JobArtifact::TERRAFORM_REPORT_FILE_TYPES) do |file_type, blob, report_artifact| + ::Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, terraform_reports, artifact: report_artifact) + end + + terraform_reports + end + def report_artifacts job_artifacts.with_reports end @@ -974,6 +983,15 @@ module Ci def has_expiring_artifacts? artifacts_expire_at.present? && artifacts_expire_at > Time.now end + + def job_jwt_variables + Gitlab::Ci::Variables::Collection.new.tap do |variables| + break variables unless Feature.enabled?(:ci_job_jwt, project, default_enabled: true) + + jwt = Gitlab::Ci::Jwt.for_build(self) + variables.append(key: 'CI_JOB_JWT', value: jwt, public: false, masked: true) + end + end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index ef0701b3874..fdb8015ba3d 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -13,6 +13,7 @@ module Ci TEST_REPORT_FILE_TYPES = %w[junit].freeze COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze NON_ERASABLE_FILE_TYPES = %w[trace].freeze + TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze DEFAULT_FILE_NAMES = { archive: nil, metadata: nil, @@ -102,6 +103,10 @@ module Ci with_file_types(COVERAGE_REPORT_FILE_TYPES) end + scope :terraform_reports, -> do + with_file_types(TERRAFORM_REPORT_FILE_TYPES) + end + scope :erasable, -> do types = self.file_types.reject { |file_type| NON_ERASABLE_FILE_TYPES.include?(file_type) }.values diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 8a3ca2e758c..4179cdbe4a3 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -817,6 +817,14 @@ module Ci end end + def terraform_reports + ::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports| + builds.latest.with_reports(::Ci::JobArtifact.terraform_reports).each do |build| + build.collect_terraform_reports!(terraform_reports) + end + end + end + def has_exposed_artifacts? complete? && builds.latest.with_exposed_artifacts.exists? end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 690aa978716..d4e9217ff9f 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -35,6 +35,7 @@ module Ci AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze + MINUTES_COST_FACTOR_FIELDS = %i[public_projects_minutes_cost_factor private_projects_minutes_cost_factor].freeze ignore_column :is_shared, remove_after: '2019-12-15', remove_with: '12.6' @@ -137,6 +138,11 @@ module Ci numericality: { greater_than_or_equal_to: 600, message: 'needs to be at least 10 minutes' } + validates :public_projects_minutes_cost_factor, :private_projects_minutes_cost_factor, + allow_nil: false, + numericality: { greater_than_or_equal_to: 0.0, + message: 'needs to be non-negative' } + # Searches for runners matching the given query. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb index afdc1c91c69..3ddb67d8427 100644 --- a/app/models/clusters/applications/elastic_stack.rb +++ b/app/models/clusters/applications/elastic_stack.rb @@ -3,7 +3,7 @@ module Clusters module Applications class ElasticStack < ApplicationRecord - VERSION = '1.9.0' + VERSION = '2.0.0' ELASTICSEARCH_PORT = 9200 @@ -28,6 +28,7 @@ module Clusters rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files, + preinstall: migrate_to_2_script, postinstall: post_install_script ) end @@ -69,6 +70,10 @@ module Clusters end end + def filebeat7? + Gem::Version.new(version) >= Gem::Version.new('2.0.0') + end + private def post_install_script @@ -86,6 +91,27 @@ module Clusters def kube_client cluster&.kubeclient&.core_client end + + def migrate_to_2_script + # Updating the chart to 2.0.0 includes an update of the filebeat chart from 1.7.0 to 3.1.1 https://github.com/helm/charts/pull/21640 + # This includes the following commit that changes labels on the filebeat deployment https://github.com/helm/charts/commit/9b009170686c6f4b202c36ceb1da4bb9ba15ddd0 + # Unfortunately those fields are immutable, and we can't use `helm upgrade` to change them. We first have to delete the associated filebeat resources + # The following pre-install command runs before updating to 2.0.0 and sets filebeat.enable=false so the filebeat deployment is deleted. + # Then the main install command re-creates them properly + if updating? && !filebeat7? + [ + Gitlab::Kubernetes::Helm::InstallCommand.new( + name: 'elastic-stack', + version: version, + rbac: cluster.platform_kubernetes_rbac?, + chart: chart, + files: files + ).install_command + ' --set filebeat.enabled\\=false' + ] + else + [] + end + end end end end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index baf34e916f8..5985e08d73e 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -30,7 +30,6 @@ module Clusters enum modsecurity_mode: { logging: 0, blocking: 1 } FETCH_IP_ADDRESS_DELAY = 30.seconds - MODSEC_SIDECAR_INITIAL_DELAY_SECONDS = 10 state_machine :status do after_transition any => [:installed] do |application| @@ -108,11 +107,13 @@ module Clusters "readOnly" => true } ], - "startupProbe" => { + "livenessProbe" => { "exec" => { - "command" => ["ls", "/var/log/modsec"] - }, - "initialDelaySeconds" => MODSEC_SIDECAR_INITIAL_DELAY_SECONDS + "command" => [ + "ls", + "/var/log/modsec/audit.log" + ] + } } } ], diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 7300283f086..37f2209b9d2 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -116,6 +116,7 @@ module Issuable # rubocop:enable GitlabSecurity/SqlInjection scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) } + scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) } scope :any_label, -> { joins(:label_links).group(:id) } scope :join_project, -> { joins(:project) } scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) } @@ -131,8 +132,21 @@ module Issuable strip_attributes :title - def self.locking_enabled? - false + class << self + def labels_hash + issue_labels = Hash.new { |h, k| h[k] = [] } + + relation = unscoped.where(id: self.select(:id)).eager_load(:labels) + relation.pluck(:id, 'labels.title').each do |issue_id, label| + issue_labels[issue_id] << label if label.present? + end + + issue_labels + end + + def locking_enabled? + false + end end # We want to use optimistic lock for cases when only title or description are involved @@ -478,5 +492,4 @@ module Issuable end end -Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule -Issuable::ClassMethods.prepend_if_ee('EE::Issuable::ClassMethods') +Issuable.prepend_if_ee('EE::Issuable') diff --git a/app/models/concerns/notification_branch_selection.rb b/app/models/concerns/notification_branch_selection.rb index 7f00b652530..2354335469a 100644 --- a/app/models/concerns/notification_branch_selection.rb +++ b/app/models/concerns/notification_branch_selection.rb @@ -6,12 +6,14 @@ module NotificationBranchSelection extend ActiveSupport::Concern - BRANCH_CHOICES = [ - [_('All branches'), 'all'], - [_('Default branch'), 'default'], - [_('Protected branches'), 'protected'], - [_('Default branch and protected branches'), 'default_and_protected'] - ].freeze + def branch_choices + [ + [_('All branches'), 'all'].freeze, + [_('Default branch'), 'default'].freeze, + [_('Protected branches'), 'protected'].freeze, + [_('Default branch and protected branches'), 'default_and_protected'].freeze + ].freeze + end def notify_for_branch?(data) ref = if data[:ref] diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 76d26500267..cedcf164a49 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -66,6 +66,10 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:pages_access_level, value) end + def metrics_dashboard_access_level=(value) + write_feature_attribute_string(:metrics_dashboard_access_level, value) + end + private def write_feature_attribute_boolean(field, value) diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 4fbb5dcb649..9cd1a22b203 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -13,9 +13,13 @@ module Spammable has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent attr_accessor :spam + attr_accessor :needs_recaptcha attr_accessor :spam_log + alias_method :spam?, :spam + alias_method :needs_recaptcha?, :needs_recaptcha + # if spam errors are added before validation, they will be wiped after_validation :invalidate_if_spam, on: [:create, :update] cattr_accessor :spammable_attrs, instance_accessor: false do @@ -38,24 +42,35 @@ module Spammable end def needs_recaptcha! - self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\ - "Please, change the content or solve the reCAPTCHA to proceed.") + self.needs_recaptcha = true end - def unrecoverable_spam_error! - self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.") + def spam! + self.spam = true end - def invalidate_if_spam - return unless spam? + def clear_spam_flags! + self.spam = false + self.needs_recaptcha = false + end - if Gitlab::Recaptcha.enabled? - needs_recaptcha! - else + def invalidate_if_spam + if needs_recaptcha? && Gitlab::Recaptcha.enabled? + recaptcha_error! + elsif needs_recaptcha? || spam? unrecoverable_spam_error! end end + def recaptcha_error! + self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\ + "Please, change the content or solve the reCAPTCHA to proceed.") + end + + def unrecoverable_spam_error! + self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.") + end + def spammable_entity_type self.class.name.underscore end diff --git a/app/models/group.rb b/app/models/group.rb index f4eaa581d54..55a2c4ba9a9 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -72,7 +72,7 @@ class Group < Namespace validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 } validates :name, format: { with: Gitlab::Regex.group_name_regex, - message: Gitlab::Regex.group_name_regex_message } + message: Gitlab::Regex.group_name_regex_message }, if: :name_changed? add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required } diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb index a1e03218640..109c0c82487 100644 --- a/app/models/import_failure.rb +++ b/app/models/import_failure.rb @@ -6,4 +6,11 @@ class ImportFailure < ApplicationRecord validates :project, presence: true, unless: :group validates :group, presence: true, unless: :project + + # Returns any `import_failures` for relations that were unrecoverable errors or failed after + # several retries. An import can be successful even if some relations failed to import correctly. + # A retry_count of 0 indicates that either no retries were attempted, or they were exceeded. + scope :hard_failures_by_correlation_id, ->(correlation_id) { + where(correlation_id_value: correlation_id, retry_count: 0).order(created_at: :desc) + } end diff --git a/app/models/internal_id_enums.rb b/app/models/internal_id_enums.rb index 2f7d7aeff2f..1b7ad00e58b 100644 --- a/app/models/internal_id_enums.rb +++ b/app/models/internal_id_enums.rb @@ -3,7 +3,7 @@ module InternalIdEnums def self.usage_resources # when adding new resource, make sure it doesn't conflict with EE usage_resources - { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5, operations_feature_flags: 6 } + { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5, operations_feature_flags: 6, operations_user_lists: 7 } end end diff --git a/app/models/jira_import_state.rb b/app/models/jira_import_state.rb index 543ee77917c..bde2795e7b8 100644 --- a/app/models/jira_import_state.rb +++ b/app/models/jira_import_state.rb @@ -53,6 +53,7 @@ class JiraImportState < ApplicationRecord before_transition any => :finished do |state, _| InternalId.flush_records!(project: state.project) state.project.update_project_counter_caches + state.store_issue_counts end after_transition any => :finished do |state, _| @@ -80,4 +81,20 @@ class JiraImportState < ApplicationRecord def non_initial? !initial? end + + def store_issue_counts + import_label_id = Gitlab::JiraImport.get_import_label_id(project.id) + + failed_to_import_count = Gitlab::JiraImport.issue_failures(project.id) + successfully_imported_count = project.issues.with_label_ids(import_label_id).count + total_issue_count = successfully_imported_count + failed_to_import_count + + update( + { + failed_to_import_count: failed_to_import_count, + imported_issues_count: successfully_imported_count, + total_issue_count: total_issue_count + } + ) + end end diff --git a/app/models/list.rb b/app/models/list.rb index 64247fdb983..ffea86dec7c 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -74,14 +74,18 @@ class List < ApplicationRecord label? ? label.name : list_type.humanize end + def collapsed?(user) + preferences = preferences_for(user) + + preferences.collapsed? + end + def as_json(options = {}) super(options).tap do |json| json[:collapsed] = false if options.key?(:collapsed) - preferences = preferences_for(options[:current_user]) - - json[:collapsed] = preferences.collapsed? + json[:collapsed] = collapsed?(options[:current_user]) end if options.key?(:label) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index c47f1af2a73..efc9e80c72d 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -163,7 +163,7 @@ class MergeRequest < ApplicationRecord state_machine :merge_status, initial: :unchecked do event :mark_as_unchecked do transition [:can_be_merged, :checking, :unchecked] => :unchecked - transition [:cannot_be_merged, :cannot_be_merged_recheck] => :cannot_be_merged_recheck + transition [:cannot_be_merged, :cannot_be_merged_rechecking, :cannot_be_merged_recheck] => :cannot_be_merged_recheck end event :mark_as_checking do @@ -200,7 +200,7 @@ class MergeRequest < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def check_state?(merge_status) - [:unchecked, :cannot_be_merged_recheck, :checking].include?(merge_status.to_sym) + [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking].include?(merge_status.to_sym) end end @@ -577,13 +577,13 @@ class MergeRequest < ApplicationRecord merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size end - def modified_paths(past_merge_request_diff: nil) + def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false) if past_merge_request_diff - past_merge_request_diff.modified_paths + past_merge_request_diff.modified_paths(fallback_on_overflow: fallback_on_overflow) elsif compare diff_stats&.paths || compare.modified_paths else - merge_request_diff.modified_paths + merge_request_diff.modified_paths(fallback_on_overflow: fallback_on_overflow) end end @@ -1325,6 +1325,10 @@ class MergeRequest < ApplicationRecord actual_head_pipeline&.has_reports?(Ci::JobArtifact.coverage_reports) end + def has_terraform_reports? + actual_head_pipeline&.has_reports?(Ci::JobArtifact.terraform_reports) + end + # TODO: this method and compare_test_reports use the same # result type, which is handled by the controller's #reports_response. # we should minimize mistakes by isolating the common parts. @@ -1337,9 +1341,15 @@ class MergeRequest < ApplicationRecord compare_reports(Ci::GenerateCoverageReportsService) end - def has_exposed_artifacts? - return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true) + def find_terraform_reports + unless has_terraform_reports? + return { status: :error, status_reason: 'This merge request does not have terraform reports' } + end + compare_reports(Ci::GenerateTerraformReportsService) + end + + def has_exposed_artifacts? actual_head_pipeline&.has_exposed_artifacts? end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 9136c6cc5d4..7b15d21c095 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -366,9 +366,22 @@ class MergeRequestDiff < ApplicationRecord end # rubocop: enable CodeReuse/ServiceClass - def modified_paths - strong_memoize(:modified_paths) do - merge_request_diff_files.pluck(:new_path, :old_path).flatten.uniq + def modified_paths(fallback_on_overflow: false) + if fallback_on_overflow && overflow? + # This is an extremely slow means to find the modified paths for a given + # MergeRequestDiff. This should be avoided, except where the limit of + # 1_000 (as of %12.10) entries returned by the default behavior is an + # issue. + strong_memoize(:overflowed_modified_paths) do + project.repository.diff_stats( + base_commit_sha, + head_commit_sha + ).paths + end + else + strong_memoize(:modified_paths) do + merge_request_diff_files.pluck(:new_path, :old_path).flatten.uniq + end end end diff --git a/app/models/namespace/root_storage_size.rb b/app/models/namespace/root_storage_size.rb new file mode 100644 index 00000000000..56e615205c4 --- /dev/null +++ b/app/models/namespace/root_storage_size.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Namespace::RootStorageSize + ALERT_USAGE_THRESHOLD = 0.5 + + def initialize(root_namespace) + @root_namespace = root_namespace + end + + def above_size_limit? + return false if limit == 0 + + usage_ratio > 1 + end + + def usage_ratio + return 0 if limit == 0 + + current_size.to_f / limit.to_f + end + + def current_size + @current_size ||= root_namespace.root_storage_statistics&.storage_size + end + + def limit + @limit ||= Gitlab::CurrentSettings.namespace_storage_size_limit.megabytes + end + + def show_alert? + return false if limit == 0 + + usage_ratio >= ALERT_USAGE_THRESHOLD + end + + private + + attr_reader :root_namespace +end diff --git a/app/models/project.rb b/app/models/project.rb index 3168def7dd8..3ff782a9643 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -340,7 +340,7 @@ class Project < ApplicationRecord :pages_enabled?, :public_pages?, :private_pages?, :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, - :repository_access_level, :pages_access_level, + :repository_access_level, :pages_access_level, :metrics_dashboard_access_level, to: :project_feature, allow_nil: true delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?, prefix: :import, to: :import_state, allow_nil: true @@ -415,7 +415,6 @@ class Project < ApplicationRecord scope :sorted_by_activity, -> { reorder(Arel.sql("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC")) } scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) } scope :sorted_by_stars_asc, -> { reorder(self.arel_table['star_count'].asc) } - scope :sorted_by_name_asc_limited, ->(limit) { reorder(name: :asc).limit(limit) } # Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) } @@ -774,10 +773,6 @@ class Project < ApplicationRecord { scope: :project, status: auto_devops&.enabled || Feature.enabled?(:force_autodevops_on_by_default, self) } end - def daily_statistics_enabled? - Feature.enabled?(:project_daily_statistics, self, default_enabled: true) - end - def unlink_forks_upon_visibility_decrease_enabled? Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true) end @@ -866,6 +861,16 @@ class Project < ApplicationRecord latest_jira_import&.status || 'initial' end + def validate_jira_import_settings!(user: nil) + raise Projects::ImportService::Error, _('Jira import feature is disabled.') unless jira_issues_import_feature_flag_enabled? + raise Projects::ImportService::Error, _('Jira integration not configured.') unless jira_service&.active? + + return unless user + + raise Projects::ImportService::Error, _('Cannot import because issues are not available in this project.') unless feature_available?(:issues, user) + raise Projects::ImportService::Error, _('You do not have permissions to run the import.') unless user.can?(:admin_project, self) + end + def human_import_status_name import_state&.human_status_name || 'none' end @@ -1174,11 +1179,7 @@ class Project < ApplicationRecord end def issues_tracker - if external_issue_tracker - external_issue_tracker - else - default_issue_tracker - end + external_issue_tracker || default_issue_tracker end def external_issue_reference_pattern @@ -1323,11 +1324,7 @@ class Project < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def owner - if group - group - else - namespace.try(:owner) - end + group || namespace.try(:owner) end def to_ability_name diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index a9753c3c53a..31a3fa12c00 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -22,7 +22,7 @@ class ProjectFeature < ApplicationRecord ENABLED = 20 PUBLIC = 30 - FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages).freeze + FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze STRING_OPTIONS = HashWithIndifferentAccess.new({ @@ -90,13 +90,14 @@ class ProjectFeature < ApplicationRecord validate :repository_children_level validate :allowed_access_levels - default_value_for :builds_access_level, value: ENABLED, allows_nil: false - default_value_for :issues_access_level, value: ENABLED, allows_nil: false - default_value_for :forking_access_level, value: ENABLED, allows_nil: false - default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false - default_value_for :snippets_access_level, value: ENABLED, allows_nil: false - default_value_for :wiki_access_level, value: ENABLED, allows_nil: false - default_value_for :repository_access_level, value: ENABLED, allows_nil: false + default_value_for :builds_access_level, value: ENABLED, allows_nil: false + default_value_for :issues_access_level, value: ENABLED, allows_nil: false + default_value_for :forking_access_level, value: ENABLED, allows_nil: false + default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false + default_value_for :snippets_access_level, value: ENABLED, allows_nil: false + default_value_for :wiki_access_level, value: ENABLED, allows_nil: false + default_value_for :repository_access_level, value: ENABLED, allows_nil: false + default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false default_value_for(:pages_access_level, allows_nil: false) do |feature| if ::Gitlab::Pages.access_control_is_forced? diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index f58b8dc624d..e434ea58729 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -72,6 +72,10 @@ class ProjectImportState < ApplicationRecord end end + def relation_hard_failures(limit:) + project.import_failures.hard_failures_by_correlation_id(correlation_id).limit(limit) + end + def mark_as_failed(error_message) original_errors = errors.dup sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message) diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 1ec983223f3..c9e97efb4ac 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -59,11 +59,11 @@ class ChatNotificationService < Service def default_fields [ - { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true }, - { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } - ] + { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true }.freeze, + { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }.freeze, + { type: 'checkbox', name: 'notify_only_broken_pipelines' }.freeze, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze + ].freeze end def execute(data) diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb index 294b286f073..941b7f64263 100644 --- a/app/models/project_services/discord_service.rb +++ b/app/models/project_services/discord_service.rb @@ -44,7 +44,7 @@ class DiscordService < ChatNotificationService [ { type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" }, { type: "checkbox", name: "notify_only_broken_pipelines" }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] end diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb index dd2f1359e76..01d8647d439 100644 --- a/app/models/project_services/emails_on_push_service.rb +++ b/app/models/project_services/emails_on_push_service.rb @@ -66,7 +66,7 @@ class EmailsOnPushService < Service help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } }, { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }, { type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') } ] end diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb index d105bd012d6..299a306add7 100644 --- a/app/models/project_services/hangouts_chat_service.rb +++ b/app/models/project_services/hangouts_chat_service.rb @@ -44,7 +44,7 @@ class HangoutsChatService < ChatNotificationService [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 3f7e8a720aa..f5d6ae10469 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -172,7 +172,7 @@ class IssueTrackerService < Service end def one_issue_tracker - return if template? + return if template? || instance? return if project.blank? if project.services.external_issue_trackers.where.not(id: id).any? diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index eaddac9cce3..f0a5d8e8cdd 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -25,6 +25,11 @@ class JiraService < IssueTrackerService before_update :reset_password + enum comment_detail: { + standard: 1, + all_details: 2 + } + alias_method :project_url, :url # When these are false GitLab does not create cross reference diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb index ca324f68d2d..0fd85e3a5a9 100644 --- a/app/models/project_services/mattermost_slash_commands_service.rb +++ b/app/models/project_services/mattermost_slash_commands_service.rb @@ -36,6 +36,10 @@ class MattermostSlashCommandsService < SlashCommandsService [[], e.message] end + def chat_responder + ::Gitlab::Chat::Responder::Mattermost + end + private def command(params) diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb index 111d010d672..e8e12a9a206 100644 --- a/app/models/project_services/microsoft_teams_service.rb +++ b/app/models/project_services/microsoft_teams_service.rb @@ -42,7 +42,7 @@ class MicrosoftTeamsService < ChatNotificationService [ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] end diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index b5e5afb6ea5..a58a264de5e 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -72,7 +72,7 @@ class PipelinesEmailService < Service name: 'notify_only_broken_pipelines' }, { type: 'select', name: 'branches_to_be_notified', - choices: BRANCH_CHOICES } + choices: branch_choices } ] end diff --git a/app/models/project_services/unify_circuit_service.rb b/app/models/project_services/unify_circuit_service.rb index 06f2d10f83b..1e12179e62a 100644 --- a/app/models/project_services/unify_circuit_service.rb +++ b/app/models/project_services/unify_circuit_service.rb @@ -38,7 +38,7 @@ class UnifyCircuitService < ChatNotificationService [ { type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, - { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES } + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] end diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index 8e66310f0c5..cd47c154eef 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -56,7 +56,7 @@ class ResourceLabelEvent < ResourceEvent end def banzai_render_context(field) - super.merge(pipeline: 'label', only_path: true) + super.merge(pipeline: :label, only_path: true) end def refresh_invalid_reference diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb index b97c02f1713..a40af22061e 100644 --- a/app/models/resource_milestone_event.rb +++ b/app/models/resource_milestone_event.rb @@ -13,9 +13,9 @@ class ResourceMilestoneEvent < ResourceEvent validate :exactly_one_issuable enum action: { - add: 1, - remove: 2 - } + add: 1, + remove: 2 + } # state is used for issue and merge request states. enum state: Issue.available_states.merge(MergeRequest.available_states) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index dbf600cf0df..7bff6d02910 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -322,6 +322,12 @@ class Snippet < ApplicationRecord ::Feature.enabled?(:version_snippets, user) && repository_exists? end + def file_name_on_repo + return if repository.empty? + + repository.ls_files(repository.root_ref).first + end + class << self # Searches for snippets with a matching title, description or file name. # diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 8ca4ee9239a..c4e047ff9d1 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -2,14 +2,25 @@ module Terraform class State < ApplicationRecord + DEFAULT = '{"version":1}'.freeze + HEX_REGEXP = %r{\A\h+\z}.freeze + UUID_LENGTH = 32 + belongs_to :project + belongs_to :locked_by_user, class_name: 'User' validates :project_id, presence: true + validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH }, + format: { with: HEX_REGEXP, message: 'only allows hex characters' } + + default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) } after_save :update_file_store, if: :saved_change_to_file? mount_uploader :file, StateUploader + default_value_for(:file) { CarrierWaveStringFile.new(DEFAULT) } + def update_file_store # The file.object_store is set during `uploader.store!` # which happens after object is inserted/updated @@ -19,5 +30,9 @@ module Terraform def file_store super || StateUploader.default_store end + + def locked? + self.lock_xid.present? + end end end diff --git a/app/models/user.rb b/app/models/user.rb index 42972477d97..1b087da3a2f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -337,7 +337,8 @@ class User < ApplicationRecord scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } scope :with_public_profile, -> { where(private_profile: false) } scope :bots, -> { where(user_type: UserTypeEnums.bots.values) } - scope :not_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.values)) } + scope :bots_without_project_bot, -> { bots.where.not(user_type: UserTypeEnums.bots[:project_bot]) } + scope :with_project_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.except(:project_bot).values)) } scope :humans, -> { where(user_type: nil) } scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do @@ -657,8 +658,10 @@ class User < ApplicationRecord UserTypeEnums.bots.has_key?(user_type) end + # The explicit check for project_bot will be removed with Bot Categorization + # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945 def internal? - ghost? || bot? + ghost? || (bot? && !project_bot?) end # We are transitioning from ghost boolean column to user_type @@ -668,12 +671,16 @@ class User < ApplicationRecord ghost end + # The explicit check for project_bot will be removed with Bot Categorization + # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945 def self.internal - where(ghost: true).or(bots) + where(ghost: true).or(bots_without_project_bot) end + # The explicit check for project_bot will be removed with Bot Categorization + # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945 def self.non_internal - without_ghosts.not_bots + without_ghosts.with_project_bots end # @@ -1720,7 +1727,7 @@ class User < ApplicationRecord # override, from Devise::Validatable def password_required? - return false if internal? + return false if internal? || project_bot? super end diff --git a/app/models/user_type_enums.rb b/app/models/user_type_enums.rb index 795cc4b2889..cb5aac89ed3 100644 --- a/app/models/user_type_enums.rb +++ b/app/models/user_type_enums.rb @@ -6,7 +6,7 @@ module UserTypeEnums end def self.bots - @bots ||= { alert_bot: 2 }.with_indifferent_access + @bots ||= { alert_bot: 2, project_bot: 6 }.with_indifferent_access end end diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb index 75b711eab5b..428fd336a32 100644 --- a/app/models/x509_certificate.rb +++ b/app/models/x509_certificate.rb @@ -26,6 +26,8 @@ class X509Certificate < ApplicationRecord validates :x509_issuer_id, presence: true + scope :by_x509_issuer, ->(issuer) { where(x509_issuer_id: issuer.id) } + after_commit :mark_commit_signatures_unverified def self.safe_create!(attributes) @@ -33,6 +35,10 @@ class X509Certificate < ApplicationRecord .safe_find_or_create_by!(subject_key_identifier: attributes[:subject_key_identifier]) end + def self.serial_numbers(issuer) + by_x509_issuer(issuer).pluck(:serial_number) + end + def mark_commit_signatures_unverified X509CertificateRevokeWorker.perform_async(self.id) if revoked? end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 2bde7bcca08..9353b361c2a 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -17,6 +17,8 @@ class GlobalPolicy < BasePolicy condition(:private_instance_statistics, score: 0) { Gitlab::CurrentSettings.instance_statistics_visibility_private? } + condition(:project_bot, scope: :user) { @user&.project_bot? } + rule { admin | (~private_instance_statistics & ~anonymous) } .enable :read_instance_statistics @@ -51,6 +53,11 @@ class GlobalPolicy < BasePolicy prevent :use_slash_commands end + rule { project_bot }.policy do + prevent :log_in + prevent :receive_notifications + end + rule { deactivated }.policy do prevent :access_git prevent :access_api diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index a34217d90dd..728c4b76498 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -91,6 +91,7 @@ class GroupPolicy < BasePolicy end rule { reporter }.policy do + enable :reporter_access enable :read_container_image enable :download_wiki_code enable :admin_label diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 7454343a357..21fa87ea9cc 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -223,6 +223,7 @@ class ProjectPolicy < BasePolicy enable :read_merge_request enable :read_sentry_issue enable :update_sentry_issue + enable :read_alert_management enable :read_prometheus enable :read_metrics_dashboard_annotation end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index ce9a3346b4b..395eaeea8de 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -36,16 +36,18 @@ module Ci end end - NAMES = { - merge_train: s_('Pipeline|Merge train pipeline'), - merged_result: s_('Pipeline|Merged result pipeline'), - detached: s_('Pipeline|Detached merge request pipeline') - }.freeze + def localized_names + { + merge_train: s_('Pipeline|Merge train pipeline'), + merged_result: s_('Pipeline|Merged result pipeline'), + detached: s_('Pipeline|Detached merge request pipeline') + }.freeze + end def name # Currently, `merge_request_event_type` is the only source to name pipelines # but this could be extended with the other types in the future. - NAMES.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline')) + localized_names.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline')) end def ref_text diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb index b9797bfb021..57e9225e2da 100644 --- a/app/serializers/analytics_summary_entity.rb +++ b/app/serializers/analytics_summary_entity.rb @@ -4,4 +4,12 @@ class AnalyticsSummaryEntity < Grape::Entity expose :value, safe: true expose :title expose :unit, if: { with_unit: true } + + private + + def value + return object.value if object.value.is_a? String + + object.value&.nonzero? ? object.value.to_s : '-' + end end diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index 18e8ec0e7d1..08255db5cbf 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -71,6 +71,12 @@ class MergeRequestPollWidgetEntity < Grape::Entity end end + expose :terraform_reports_path do |merge_request| + if merge_request.has_terraform_reports? + terraform_reports_project_merge_request_path(merge_request.project, merge_request, format: :json) + end + end + expose :exposed_artifacts_path do |merge_request| if merge_request.has_exposed_artifacts? exposed_artifacts_project_merge_request_path(merge_request.project, merge_request, format: :json) diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index aa67cd1f39e..9fd50c8c51d 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -15,6 +15,10 @@ class MergeRequestSerializer < BaseSerializer MergeRequestBasicEntity when 'noteable' MergeRequestNoteableEntity + when 'poll_cached_widget' + MergeRequestPollCachedWidgetEntity + when 'poll_widget' + MergeRequestPollWidgetEntity else # fallback to widget for old poll requests without `serializer` set MergeRequestWidgetEntity diff --git a/app/serializers/test_suite_entity.rb b/app/serializers/test_suite_entity.rb index 0f88a496c77..53fa830718a 100644 --- a/app/serializers/test_suite_entity.rb +++ b/app/serializers/test_suite_entity.rb @@ -9,8 +9,9 @@ class TestSuiteEntity < Grape::Entity expose :failed_count expose :skipped_count expose :error_count + expose :suite_error expose :test_cases, using: TestCaseEntity do |test_suite| - test_suite.test_cases.values.flat_map(&:values) + test_suite.suite_error ? [] : test_suite.test_cases.values.flat_map(&:values) end end diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index e08b4ac2260..1de2f31f87c 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -49,6 +49,14 @@ module AutoMerge end end + def available_for?(merge_request) + strong_memoize("available_for_#{merge_request.id}") do + merge_request.can_be_merged_by?(current_user) && + merge_request.mergeable_state?(skip_ci_check: true) && + yield + end + end + private def strategy diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb index 7c0e9228b28..9ae5bd1b5ec 100644 --- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb +++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb @@ -30,7 +30,9 @@ module AutoMerge end def available_for?(merge_request) - merge_request.actual_head_pipeline&.active? + super do + merge_request.actual_head_pipeline&.active? + end end end end diff --git a/app/services/auto_merge_service.rb b/app/services/auto_merge_service.rb index eee227be202..c5cbcc7c93b 100644 --- a/app/services/auto_merge_service.rb +++ b/app/services/auto_merge_service.rb @@ -1,23 +1,26 @@ # frozen_string_literal: true class AutoMergeService < BaseService + include Gitlab::Utils::StrongMemoize + STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS = 'merge_when_pipeline_succeeds' STRATEGIES = [STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS].freeze class << self - def all_strategies + def all_strategies_ordered_by_preference STRATEGIES end def get_service_class(strategy) - return unless all_strategies.include?(strategy) + return unless all_strategies_ordered_by_preference.include?(strategy) "::AutoMerge::#{strategy.camelize}Service".constantize end end - def execute(merge_request, strategy) - service = get_service_instance(strategy) + def execute(merge_request, strategy = nil) + strategy ||= preferred_strategy(merge_request) + service = get_service_instance(merge_request, strategy) return :failed unless service&.available_for?(merge_request) @@ -27,37 +30,47 @@ class AutoMergeService < BaseService def update(merge_request) return :failed unless merge_request.auto_merge_enabled? - get_service_instance(merge_request.auto_merge_strategy).update(merge_request) + strategy = merge_request.auto_merge_strategy + get_service_instance(merge_request, strategy).update(merge_request) end def process(merge_request) return unless merge_request.auto_merge_enabled? - get_service_instance(merge_request.auto_merge_strategy).process(merge_request) + strategy = merge_request.auto_merge_strategy + get_service_instance(merge_request, strategy).process(merge_request) end def cancel(merge_request) return error("Can't cancel the automatic merge", 406) unless merge_request.auto_merge_enabled? - get_service_instance(merge_request.auto_merge_strategy).cancel(merge_request) + strategy = merge_request.auto_merge_strategy + get_service_instance(merge_request, strategy).cancel(merge_request) end def abort(merge_request, reason) return error("Can't abort the automatic merge", 406) unless merge_request.auto_merge_enabled? - get_service_instance(merge_request.auto_merge_strategy).abort(merge_request, reason) + strategy = merge_request.auto_merge_strategy + get_service_instance(merge_request, strategy).abort(merge_request, reason) end def available_strategies(merge_request) - self.class.all_strategies.select do |strategy| - get_service_instance(strategy).available_for?(merge_request) + self.class.all_strategies_ordered_by_preference.select do |strategy| + get_service_instance(merge_request, strategy).available_for?(merge_request) end end + def preferred_strategy(merge_request) + available_strategies(merge_request).first + end + private - def get_service_instance(strategy) - self.class.get_service_class(strategy)&.new(project, current_user, params) + def get_service_instance(merge_request, strategy) + strong_memoize("service_instance_#{merge_request.id}_#{strategy}") do + self.class.get_service_class(strategy)&.new(project, current_user, params) + end end end diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb index c96ea970943..07ce58b6851 100644 --- a/app/services/boards/lists/list_service.rb +++ b/app/services/boards/lists/list_service.rb @@ -3,8 +3,10 @@ module Boards module Lists class ListService < Boards::BaseService - def execute(board) - board.lists.create(list_type: :backlog) unless board.lists.backlog.exists? + def execute(board, create_default_lists: true) + if create_default_lists && !board.lists.backlog.exists? + board.lists.create(list_type: :backlog) + end board.lists.preload_associated_models end diff --git a/app/services/ci/generate_terraform_reports_service.rb b/app/services/ci/generate_terraform_reports_service.rb new file mode 100644 index 00000000000..d768ce777d4 --- /dev/null +++ b/app/services/ci/generate_terraform_reports_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Ci + # TODO: a couple of points with this approach: + # + reuses existing architecture and reactive caching + # - it's not a report comparison and some comparing features must be turned off. + # see CompareReportsBaseService for more notes. + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + class GenerateTerraformReportsService < CompareReportsBaseService + def execute(base_pipeline, head_pipeline) + { + status: :parsed, + key: key(base_pipeline, head_pipeline), + data: head_pipeline.terraform_reports.plans + } + rescue => e + Gitlab::ErrorTracking.track_exception(e, project_id: project.id) + { + status: :error, + key: key(base_pipeline, head_pipeline), + status_reason: _('An error occurred while fetching terraform reports.') + } + end + + def latest?(base_pipeline, head_pipeline, data) + data&.fetch(:key, nil) == key(base_pipeline, head_pipeline) + end + end +end diff --git a/app/services/concerns/deploy_token_methods.rb b/app/services/concerns/deploy_token_methods.rb index c875342a07c..f59a50d6878 100644 --- a/app/services/concerns/deploy_token_methods.rb +++ b/app/services/concerns/deploy_token_methods.rb @@ -14,4 +14,12 @@ module DeployTokenMethods deploy_token.destroy end + + def create_deploy_token_payload_for(deploy_token) + if deploy_token.persisted? + success(deploy_token: deploy_token, http_status: :created) + else + error(deploy_token.errors.full_messages.to_sentence, :bad_request, pass_back: { deploy_token: deploy_token }) + end + end end diff --git a/app/services/concerns/spam_check_methods.rb b/app/services/concerns/spam_check_methods.rb index 695bdf92b49..84a79261915 100644 --- a/app/services/concerns/spam_check_methods.rb +++ b/app/services/concerns/spam_check_methods.rb @@ -23,7 +23,7 @@ module SpamCheckMethods # attribute values. # rubocop:disable Gitlab/ModuleWithInstanceVariables def spam_check(spammable, user) - Spam::SpamCheckService.new( + Spam::SpamActionService.new( spammable: spammable, request: @request ).execute( diff --git a/app/services/emails/destroy_service.rb b/app/services/emails/destroy_service.rb index a0b43ad3d08..6e671f52d57 100644 --- a/app/services/emails/destroy_service.rb +++ b/app/services/emails/destroy_service.rb @@ -13,7 +13,7 @@ module Emails user.update_secondary_emails! end - result[:status] == 'success' + result[:status] == :success end end end diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb index da45bcc7eaa..5c1ee981d0c 100644 --- a/app/services/git/branch_push_service.rb +++ b/app/services/git/branch_push_service.rb @@ -36,6 +36,8 @@ module Git # Update merge requests that may be affected by this push. A new branch # could cause the last commit of a merge request to change. def enqueue_update_mrs + return if params[:merge_request_branches]&.exclude?(branch_name) + UpdateMergeRequestsWorker.perform_async( project.id, current_user.id, diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb index 387cd29d69d..6d1ff97016b 100644 --- a/app/services/git/process_ref_changes_service.rb +++ b/app/services/git/process_ref_changes_service.rb @@ -42,6 +42,7 @@ module Git push_service_class = push_service_class_for(ref_type) create_bulk_push_event = changes.size > Gitlab::CurrentSettings.push_event_activities_limit + merge_request_branches = merge_request_branches_for(changes) changes.each do |change| push_service_class.new( @@ -49,6 +50,7 @@ module Git current_user, change: change, push_options: params[:push_options], + merge_request_branches: merge_request_branches, create_pipelines: change[:index] < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, project), execute_project_hooks: execute_project_hooks, create_push_event: !create_bulk_push_event @@ -71,5 +73,11 @@ module Git Git::BranchPushService end + + def merge_request_branches_for(changes) + return if Feature.disabled?(:refresh_only_existing_merge_requests_on_push, default_enabled: true) + + @merge_requests_branches ||= MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute + end end end diff --git a/app/services/groups/deploy_tokens/create_service.rb b/app/services/groups/deploy_tokens/create_service.rb index 81f761eb61d..aee423659ef 100644 --- a/app/services/groups/deploy_tokens/create_service.rb +++ b/app/services/groups/deploy_tokens/create_service.rb @@ -8,11 +8,7 @@ module Groups def execute deploy_token = create_deploy_token_for(@group, params) - if deploy_token.persisted? - success(deploy_token: deploy_token, http_status: :created) - else - error(deploy_token.errors.full_messages.to_sentence, :bad_request) - end + create_deploy_token_payload_for(deploy_token) end end end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 4e7875e0491..fe3ab884302 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -2,15 +2,6 @@ module Groups class TransferService < Groups::BaseService - ERROR_MESSAGES = { - database_not_supported: s_('TransferGroup|Database is not supported.'), - namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'), - group_is_already_root: s_('TransferGroup|Group is already a root group.'), - same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'), - invalid_policies: s_("TransferGroup|You don't have enough permissions."), - group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.') - }.freeze - TransferError = Class.new(StandardError) attr_reader :error, :new_parent_group @@ -124,7 +115,18 @@ module Groups end def raise_transfer_error(message) - raise TransferError, ERROR_MESSAGES[message] + raise TransferError, localized_error_messages[message] + end + + def localized_error_messages + { + database_not_supported: s_('TransferGroup|Database is not supported.'), + namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'), + group_is_already_root: s_('TransferGroup|Group is already a root group.'), + same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'), + invalid_policies: s_("TransferGroup|You don't have enough permissions."), + group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.') + }.freeze end end end diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb index 54576e82030..0d1640924e5 100644 --- a/app/services/issuable/clone/base_service.rb +++ b/app/services/issuable/clone/base_service.rb @@ -47,7 +47,7 @@ module Issuable end def new_parent - new_entity.project ? new_entity.project : new_entity.group + new_entity.project || new_entity.group end def group diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb new file mode 100644 index 00000000000..1dcdfb9faea --- /dev/null +++ b/app/services/issues/export_csv_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Issues + class ExportCsvService + include Gitlab::Routing.url_helpers + include GitlabRoutingHelper + + # Target attachment size before base64 encoding + TARGET_FILESIZE = 15000000 + + attr_reader :project + + def initialize(issues_relation, project) + @issues = issues_relation + @labels = @issues.labels_hash + @project = project + end + + def csv_data + csv_builder.render(TARGET_FILESIZE) + end + + def email(user) + Notify.issues_csv_email(user, project, csv_data, csv_builder.status).deliver_now + end + + # rubocop: disable CodeReuse/ActiveRecord + def csv_builder + @csv_builder ||= + CsvBuilder.new(@issues.preload(associations_to_preload), header_to_value_hash) + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def associations_to_preload + %i(author assignees timelogs) + end + + def header_to_value_hash + { + 'Issue ID' => 'iid', + 'URL' => -> (issue) { issue_url(issue) }, + 'Title' => 'title', + 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' }, + 'Description' => 'description', + 'Author' => 'author_name', + 'Author Username' => -> (issue) { issue.author&.username }, + 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') }, + 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') }, + 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' }, + 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' }, + 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) }, + 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) }, + 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) }, + 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) }, + 'Milestone' => -> (issue) { issue.milestone&.title }, + 'Weight' => -> (issue) { issue.weight }, + 'Labels' => -> (issue) { issue_labels(issue) }, + 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) }, + 'Time Spent' => -> (issue) { issue_time_spent(issue) } + } + end + + def issue_labels(issue) + @labels[issue.id].sort.join(',').presence + end + + # rubocop: disable CodeReuse/ActiveRecord + def issue_time_spent(issue) + issue.timelogs.map(&:time_spent).sum + end + # rubocop: enable CodeReuse/ActiveRecord + end +end + +Issues::ExportCsvService.prepend_if_ee('EE::Issues::ExportCsvService') diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb index e8d9e6734bd..59fd463022f 100644 --- a/app/services/jira_import/start_import_service.rb +++ b/app/services/jira_import/start_import_service.rb @@ -56,18 +56,18 @@ module JiraImport import_start_time = Time.zone.now jira_imports_for_project = project.jira_imports.by_jira_project_key(jira_project_key).size + 1 title = "jira-import::#{jira_project_key}-#{jira_imports_for_project}" - description = "Label for issues that were imported from jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}" + description = "Label for issues that were imported from Jira on #{import_start_time.strftime('%Y-%m-%d %H:%M:%S')}" color = "#{Label.color_for(title)}" { title: title, description: description, color: color } end def validate - return build_error_response(_('Jira import feature is disabled.')) unless project.jira_issues_import_feature_flag_enabled? - return build_error_response(_('You do not have permissions to run the import.')) unless user.can?(:admin_project, project) - return build_error_response(_('Cannot import because issues are not available in this project.')) unless project.feature_available?(:issues, user) - return build_error_response(_('Jira integration not configured.')) unless project.jira_service&.active? + project.validate_jira_import_settings!(user: user) + return build_error_response(_('Unable to find Jira project to import data from.')) if jira_project_key.blank? return build_error_response(_('Jira import is already running.')) if import_in_progress? + rescue Projects::ImportService::Error => e + build_error_response(e.message) end def build_error_response(message) diff --git a/app/services/merge_requests/merge_orchestration_service.rb b/app/services/merge_requests/merge_orchestration_service.rb new file mode 100644 index 00000000000..24341ef1145 --- /dev/null +++ b/app/services/merge_requests/merge_orchestration_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeOrchestrationService < ::BaseService + def execute(merge_request) + return unless can_merge?(merge_request) + + merge_request.update(merge_error: nil) + + if can_merge_automatically?(merge_request) + auto_merge_service.execute(merge_request) + else + merge_request.merge_async(current_user.id, params) + end + end + + def can_merge?(merge_request) + can_merge_automatically?(merge_request) || can_merge_immediately?(merge_request) + end + + def preferred_auto_merge_strategy(merge_request) + auto_merge_service.preferred_strategy(merge_request) + end + + private + + def can_merge_immediately?(merge_request) + merge_request.can_be_merged_by?(current_user) && + merge_request.mergeable_state? + end + + def can_merge_automatically?(merge_request) + auto_merge_service.available_strategies(merge_request).any? + end + + def auto_merge_service + @auto_merge_service ||= AutoMergeService.new(project, current_user, params) + end + end +end diff --git a/app/services/merge_requests/pushed_branches_service.rb b/app/services/merge_requests/pushed_branches_service.rb new file mode 100644 index 00000000000..afcf0f7678a --- /dev/null +++ b/app/services/merge_requests/pushed_branches_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module MergeRequests + class PushedBranchesService < MergeRequests::BaseService + include ::Gitlab::Utils::StrongMemoize + + # Skip moving this logic into models since it's too specific + # rubocop: disable CodeReuse/ActiveRecord + def execute + return [] if branch_names.blank? + + source_branches = project.source_of_merge_requests.opened + .from_source_branches(branch_names).pluck(:source_branch) + + target_branches = project.merge_requests.opened + .by_target_branch(branch_names).distinct.pluck(:target_branch) + + source_branches.concat(target_branches).to_set + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def branch_names + strong_memoize(:branch_names) do + params[:changes].map do |change| + Gitlab::Git.branch_name(change[:ref]) + end.compact + end + end + end +end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 1516e33a7c6..2d33e87bf4b 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -79,14 +79,21 @@ module MergeRequests def merge_from_quick_action(merge_request) last_diff_sha = params.delete(:merge) - return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha) - merge_request.update(merge_error: nil) - - if merge_request.head_pipeline_active? - AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) + if Feature.enabled?(:merge_orchestration_service, merge_request.project, default_enabled: true) + MergeRequests::MergeOrchestrationService + .new(project, current_user, { sha: last_diff_sha }) + .execute(merge_request) else - merge_request.merge_async(current_user.id, { sha: last_diff_sha }) + return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha) + + merge_request.update(merge_error: nil) + + if merge_request.head_pipeline_active? + AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) + else + merge_request.merge_async(current_user.id, { sha: last_diff_sha }) + end end end diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb index 035707dceb9..ce81f337e47 100644 --- a/app/services/metrics/dashboard/transient_embed_service.rb +++ b/app/services/metrics/dashboard/transient_embed_service.rb @@ -30,6 +30,11 @@ module Metrics def sequence [STAGES::EndpointInserter] end + + override :identifiers + def identifiers + Digest::SHA256.hexdigest(params[:embed_json]) + end end end end diff --git a/app/services/namespaces/check_storage_size_service.rb b/app/services/namespaces/check_storage_size_service.rb new file mode 100644 index 00000000000..8f06d2b7fee --- /dev/null +++ b/app/services/namespaces/check_storage_size_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Namespaces + class CheckStorageSizeService + include ActiveSupport::NumberHelper + + def initialize(namespace) + @root_namespace = namespace.root_ancestor + @root_storage_size = Namespace::RootStorageSize.new(root_namespace) + end + + def execute + return ServiceResponse.success unless Feature.enabled?(:namespace_storage_limit, root_namespace) + return ServiceResponse.success unless root_storage_size.show_alert? + + if root_storage_size.above_size_limit? + ServiceResponse.error(message: above_size_limit_message, payload: payload) + else + ServiceResponse.success(message: info_message, payload: payload) + end + end + + private + + attr_reader :root_namespace, :root_storage_size + + def payload + { + current_usage_message: current_usage_message, + usage_ratio: root_storage_size.usage_ratio + } + end + + def current_usage_message + params = { + usage_in_percent: number_to_percentage(root_storage_size.usage_ratio * 100, precision: 0), + namespace_name: root_namespace.name, + used_storage: formatted(root_storage_size.current_size), + storage_limit: formatted(root_storage_size.limit) + } + s_("You reached %{usage_in_percent} of %{namespace_name}'s capacity (%{used_storage} of %{storage_limit})" % params) + end + + def info_message + s_("If you reach 100%% storage capacity, you will not be able to: %{base_message}" % { base_message: base_message } ) + end + + def above_size_limit_message + s_("%{namespace_name} is now read-only. You cannot: %{base_message}" % { namespace_name: root_namespace.name, base_message: base_message }) + end + + def base_message + s_("push to your repository, create pipelines, create issues or add comments. To reduce storage capacity, delete unused repositories, artifacts, wikis, issues, and pipelines.") + end + + def formatted(number) + number_to_human_size(number, delimiter: ',', precision: 2) + end + end +end diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb new file mode 100644 index 00000000000..ff9bb7d6802 --- /dev/null +++ b/app/services/personal_access_tokens/create_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module PersonalAccessTokens + class CreateService < BaseService + def initialize(current_user, params = {}) + @current_user = current_user + @params = params.dup + end + + def execute + personal_access_token = current_user.personal_access_tokens.create(params.slice(*allowed_params)) + + if personal_access_token.persisted? + ServiceResponse.success(payload: { personal_access_token: personal_access_token }) + else + ServiceResponse.error(message: personal_access_token.errors.full_messages.to_sentence) + end + end + + private + + def allowed_params + [ + :name, + :impersonation, + :scopes, + :expires_at + ] + end + end +end diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb index aac0fa424ca..9a9b453c554 100644 --- a/app/services/pod_logs/elasticsearch_service.rb +++ b/app/services/pod_logs/elasticsearch_service.rb @@ -65,6 +65,8 @@ module PodLogs client = cluster&.application_elastic_stack&.elasticsearch_client return error(_('Unable to connect to Elasticsearch')) unless client + filebeat7 = cluster.application_elastic_stack.filebeat7? + response = ::Gitlab::Elasticsearch::Logs::Lines.new(client).pod_logs( namespace, pod_name: result[:pod_name], @@ -72,7 +74,8 @@ module PodLogs search: result[:search], start_time: result[:start_time], end_time: result[:end_time], - cursor: result[:cursor] + cursor: result[:cursor], + filebeat7: filebeat7 ) result.merge!(response) diff --git a/app/services/projects/deploy_tokens/create_service.rb b/app/services/projects/deploy_tokens/create_service.rb index 2e71650b066..592198ef241 100644 --- a/app/services/projects/deploy_tokens/create_service.rb +++ b/app/services/projects/deploy_tokens/create_service.rb @@ -8,11 +8,7 @@ module Projects def execute deploy_token = create_deploy_token_for(@project, params) - if deploy_token.persisted? - success(deploy_token: deploy_token, http_status: :created) - else - error(deploy_token.errors.full_messages.to_sentence, :bad_request) - end + create_deploy_token_payload_for(deploy_token) end end end diff --git a/app/services/resources/create_access_token_service.rb b/app/services/resources/create_access_token_service.rb new file mode 100644 index 00000000000..fd3c8d78e58 --- /dev/null +++ b/app/services/resources/create_access_token_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Resources + class CreateAccessTokenService < BaseService + attr_accessor :resource_type, :resource + + def initialize(resource_type, resource, user, params = {}) + @resource_type = resource_type + @resource = resource + @current_user = user + @params = params.dup + end + + def execute + return unless feature_enabled? + return error("User does not have permission to create #{resource_type} Access Token") unless has_permission_to_create? + + # We skip authorization by default, since the user creating the bot is not an admin + # and project/group bot users are not created via sign-up + user = create_user + + return error(user.errors.full_messages.to_sentence) unless user.persisted? + return error("Failed to provide maintainer access") unless provision_access(resource, user) + + token_response = create_personal_access_token(user) + + if token_response.success? + success(token_response.payload[:personal_access_token]) + else + error(token_response.message) + end + end + + private + + def feature_enabled? + ::Feature.enabled?(:resource_access_token, resource) + end + + def has_permission_to_create? + case resource_type + when 'project' + can?(current_user, :admin_project, resource) + when 'group' + can?(current_user, :admin_group, resource) + else + false + end + end + + def create_user + Users::CreateService.new(current_user, default_user_params).execute(skip_authorization: true) + end + + def default_user_params + { + name: params[:name] || "#{resource.name.to_s.humanize} bot", + email: generate_email, + username: generate_username, + user_type: "#{resource_type}_bot".to_sym + } + end + + def generate_username + base_username = "#{resource_type}_#{resource.id}_bot" + + uniquify.string(base_username) { |s| User.find_by_username(s) } + end + + def generate_email + email_pattern = "#{resource_type}#{resource.id}_bot%s@example.com" + + uniquify.string(-> (n) { Kernel.sprintf(email_pattern, n) }) do |s| + User.find_by_email(s) + end + end + + def uniquify + Uniquify.new + end + + def create_personal_access_token(user) + PersonalAccessTokens::CreateService.new(user, personal_access_token_params).execute + end + + def personal_access_token_params + { + name: "#{resource_type}_bot", + impersonation: false, + scopes: params[:scopes] || default_scopes, + expires_at: params[:expires_at] || nil + } + end + + def default_scopes + Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user] + end + + def provision_access(resource, user) + resource.add_maintainer(user) + end + + def error(message) + ServiceResponse.error(message: message) + end + + def success(access_token) + ServiceResponse.success(payload: { access_token: access_token }) + end + end +end diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index 0b74bd77e28..155013db344 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -38,9 +38,7 @@ module Snippets private def save_and_commit - snippet_saved = @snippet.with_transaction_returning_status do - @snippet.save && @snippet.store_mentions! - end + snippet_saved = @snippet.save if snippet_saved && Feature.enabled?(:version_snippets, current_user) create_repository diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index e56b20c6057..e56e01cf82b 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -4,6 +4,8 @@ module Snippets class UpdateService < Snippets::BaseService include SpamCheckMethods + COMMITTABLE_ATTRIBUTES = %w(file_name content).freeze + UpdateError = Class.new(StandardError) CreateRepositoryError = Class.new(StandardError) @@ -37,6 +39,10 @@ module Snippets def save_and_commit(snippet) return false unless snippet.save + # If the updated attributes does not need to update + # the repository we can just return + return true unless committable_attributes? + # In order to avoid non migrated snippets scenarios, # if the snippet does not have a repository we created it # We don't need to check if the repository exists @@ -104,5 +110,9 @@ module Snippets def repository_empty?(snippet) snippet.repository._uncached_exists? && !snippet.repository._uncached_has_visible_content? end + + def committable_attributes? + (params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present? + end end end diff --git a/app/services/spam/spam_check_service.rb b/app/services/spam/spam_action_service.rb index 3269f9d687a..9ab76b27f4a 100644 --- a/app/services/spam/spam_check_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true module Spam - class SpamCheckService - include AkismetMethods + class SpamActionService + include SpamConstants attr_accessor :target, :request, :options attr_reader :spam_log @@ -28,27 +28,40 @@ module Spam # update the spam log accordingly. SpamLog.verify_recaptcha!(user_id: user_id, id: spam_log_id) else - # Otherwise, it goes to Akismet for spam check. - # If so, it assigns spammable object as "spam" and creates a SpamLog record. - possible_spam = check(api) - target.spam = possible_spam unless target.allow_possible_spam? - target.spam_log = spam_log + return unless request + return unless check_for_spam? + + perform_spam_service_check(api) end end + delegate :check_for_spam?, to: :target + private - def check(api) - return unless request - return unless check_for_spam? - return unless akismet.spam? + def perform_spam_service_check(api) + # since we can check for spam, and recaptcha is not verified, + # ask the SpamVerdictService what to do with the target. + spam_verdict_service.execute.tap do |result| + case result + when REQUIRE_RECAPTCHA + create_spam_log(api) - create_spam_log(api) - true - end + break if target.allow_possible_spam? - def check_for_spam? - target.check_for_spam? + # TODO: remove spam! declaration + # https://gitlab.com/gitlab-org/gitlab/-/issues/214738 + target.spam! + target.needs_recaptcha! + when DISALLOW + # TODO: remove `unless target.allow_possible_spam?` once this flag has been passed to `SpamVerdictService` + # https://gitlab.com/gitlab-org/gitlab/-/issues/214739 + target.spam! unless target.allow_possible_spam? + create_spam_log(api) + when ALLOW + target.clear_spam_flags! + end + end end def create_spam_log(api) @@ -63,6 +76,14 @@ module Spam via_api: api } ) + + target.spam_log = spam_log + end + + def spam_verdict_service + SpamVerdictService.new(target: target, + request: @request, + options: options) end end end diff --git a/app/services/spam/spam_constants.rb b/app/services/spam/spam_constants.rb new file mode 100644 index 00000000000..085bac684c4 --- /dev/null +++ b/app/services/spam/spam_constants.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Spam + module SpamConstants + REQUIRE_RECAPTCHA = :recaptcha + DISALLOW = :disallow + ALLOW = :allow + end +end diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb new file mode 100644 index 00000000000..2b4d5f4a984 --- /dev/null +++ b/app/services/spam/spam_verdict_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Spam + class SpamVerdictService + include AkismetMethods + include SpamConstants + + def initialize(target:, request:, options:) + @target = target + @request = request + @options = options + end + + def execute + if akismet.spam? + Gitlab::Recaptcha.enabled? ? REQUIRE_RECAPTCHA : DISALLOW + else + ALLOW + end + end + + private + + attr_reader :target, :request, :options + end +end diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb new file mode 100644 index 00000000000..5bb6f6a1dee --- /dev/null +++ b/app/services/terraform/remote_state_handler.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Terraform + class RemoteStateHandler < BaseService + include Gitlab::OptimisticLocking + + StateLockedError = Class.new(StandardError) + + # rubocop: disable CodeReuse/ActiveRecord + def find_with_lock + raise ArgumentError unless params[:name].present? + + state = Terraform::State.find_by(project: project, name: params[:name]) + raise ActiveRecord::RecordNotFound.new("Couldn't find state") unless state + + retry_optimistic_lock(state) { |state| yield state } if state && block_given? + state + end + # rubocop: enable CodeReuse/ActiveRecord + + def create_or_find! + raise ArgumentError unless params[:name].present? + + Terraform::State.create_or_find_by(project: project, name: params[:name]) + end + + def handle_with_lock + retrieve_with_lock do |state| + raise StateLockedError unless lock_matches?(state) + + yield state if block_given? + + state.save! unless state.destroyed? + end + end + + def lock! + raise ArgumentError if params[:lock_id].blank? + + retrieve_with_lock do |state| + raise StateLockedError if state.locked? + + state.lock_xid = params[:lock_id] + state.locked_by_user = current_user + state.locked_at = Time.now + + state.save! + end + end + + def unlock! + retrieve_with_lock do |state| + # force-unlock does not pass ID, so we ignore it if it is missing + raise StateLockedError unless params[:lock_id].nil? || lock_matches?(state) + + state.lock_xid = nil + state.locked_by_user = nil + state.locked_at = nil + + state.save! + end + end + + private + + def retrieve_with_lock + create_or_find!.tap { |state| retry_optimistic_lock(state) { |state| yield state } } + end + + def lock_matches?(state) + return true if state.lock_xid.nil? && params[:lock_id].nil? + + ActiveSupport::SecurityUtils + .secure_compare(state.lock_xid.to_s, params[:lock_id].to_s) + end + end +end diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index 6f9f307c322..3938d675596 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -81,7 +81,8 @@ module Users :private_profile, :organization, :location, - :public_email + :public_email, + :user_type ] end @@ -95,7 +96,8 @@ module Users :first_name, :last_name, :password, - :username + :username, + :user_type ] end @@ -127,6 +129,8 @@ module Users user_params[:external] = user_external? end + user_params.delete(:user_type) unless project_bot?(user_params[:user_type]) + user_params end @@ -137,6 +141,10 @@ module Users def user_external? user_default_internal_regex_instance.match(params[:email]).nil? end + + def project_bot?(user_type) + user_type&.to_sym == :project_bot + end end end diff --git a/app/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb index 9c5ae8a8bdc..2306313fc82 100644 --- a/app/uploaders/terraform/state_uploader.rb +++ b/app/uploaders/terraform/state_uploader.rb @@ -12,7 +12,7 @@ module Terraform encrypt(key: :key) def filename - "#{model.id}.tfstate" + "#{model.uuid}.tfstate" end def store_dir diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index f860b7a61a2..0120d4038b9 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -28,7 +28,7 @@ %hr .append-bottom-20 - = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner) + = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com? .row .col-md-6 diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 3fa957f38a0..4d8df4cc12a 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -5,7 +5,7 @@ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protected-variables') } = s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } -- if Feature.enabled?(:new_variables_ui, @project || @group) +- if Feature.enabled?(:new_variables_ui, @project || @group, default_enabled: true) - is_group = !@group.nil? #js-ci-project-variables{ data: { endpoint: save_endpoint, project_id: @project&.id || '', group: is_group.to_s, maskable_regex: ci_variable_maskable_regex} } diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 7fc76880480..1cc68d927bd 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -17,6 +17,7 @@ install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative), update_knative_path: clusterable.update_applications_cluster_path(@cluster, :knative), install_elastic_stack_path: clusterable.install_applications_cluster_path(@cluster, :elastic_stack), + install_fluentd_path: clusterable.install_applications_cluster_path(@cluster, :fluentd), cluster_environments_path: cluster_environments_path, toggle_status: @cluster.enabled? ? 'true': 'false', has_rbac: has_rbac_enabled?(@cluster) ? 'true': 'false', diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 4aef30622cd..8c9b859e127 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -3,7 +3,6 @@ - expanded = expanded_by_default? - general_expanded = @group.errors.empty? ? expanded : true -- deploy_token_description = s_('DeployTokens|Group deploy tokens allow read-only access to the repositories and registry images within the group.') -# Given we only have one field in this form which is also admin-only, -# we don't want to show an empty section to non-admin users, @@ -25,8 +24,6 @@ .settings-content = render 'ci/variables/index', save_endpoint: group_variables_path -= render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description - %section.settings#runners-settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml new file mode 100644 index 00000000000..1f1d7779267 --- /dev/null +++ b/app/views/groups/settings/repository/show.html.haml @@ -0,0 +1,6 @@ +- breadcrumb_title _('Repository Settings') +- page_title _('Repository') + +- deploy_token_description = s_('DeployTokens|Group deploy tokens allow read-only access to the repositories and registry images within the group.') + += render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index 4b9304cfdb9..aa63127049c 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -65,25 +65,23 @@ %tbody %tr %th - %th= _('Web IDE') + %th= _('Editing') %tr %td.shortcut - if browser.platform.mac? - %kbd ⌘ p + %kbd ⌘ shift p - else - %kbd ctrl p - %td= _('Go to file') + %kbd ctrl shift p + %td= _('Toggle Markdown preview') %tr %td.shortcut - - if browser.platform.mac? - %kbd ⌘ enter - - else - %kbd ctrl enter - %td= _('Commit (when editing commit message)') + %kbd + %i.fa.fa-arrow-up + %td= _('Edit your most recent comment in a thread (from an empty textarea)') %tbody %tr %th - %th= _('Wiki pages') + %th= _('Wiki') %tr %td.shortcut %kbd e @@ -91,19 +89,49 @@ %tbody %tr %th - %th= _('Editing') + %th= _('Repository Graph') %tr %td.shortcut - - if browser.platform.mac? - %kbd ⌘ shift p - - else - %kbd ctrl shift p - %td= _('Toggle Markdown preview') + %kbd + %i.fa.fa-arrow-left + \/ + %kbd h + %td= _('Scroll left') + %tr + %td.shortcut + %kbd + %i.fa.fa-arrow-right + \/ + %kbd l + %td= _('Scroll right') %tr %td.shortcut %kbd %i.fa.fa-arrow-up - %td= _('Edit your most recent comment in a thread (from an empty textarea)') + \/ + %kbd k + %td= _('Scroll up') + %tr + %td.shortcut + %kbd + %i.fa.fa-arrow-down + \/ + %kbd j + %td= _('Scroll down') + %tr + %td.shortcut + %kbd + shift + %i.fa.fa-arrow-up + \/ k + %td= _('Scroll to top') + %tr + %td.shortcut + %kbd + shift + %i.fa.fa-arrow-down + \/ j + %td= _('Scroll to bottom') .col-lg-4 %table.shortcut-mappings.text-2 %tbody @@ -229,15 +257,7 @@ %tbody %tr %th - %th= _('Issues / Merge Requests') - %tr - %td.shortcut - %kbd a - %td= _('Change assignee') - %tr - %td.shortcut - %kbd m - %td= _('Change milestone') + %th= _('Epics, Issues, and Merge Requests') %tr %td.shortcut %kbd r @@ -250,92 +270,64 @@ %td.shortcut %kbd l %td= _('Change label') + %tbody + %tr + %th + %th= _('Issues and Merge Requests') + %tr + %td.shortcut + %kbd a + %td= _('Change assignee') + %tr + %td.shortcut + %kbd m + %td= _('Change milestone') + %tbody + %tr + %th + %th= _('Merge Requests') %tr %td.shortcut %kbd ] \/ %kbd j - %td= _('Next file in diff (MRs only)') + %td= _('Next file in diff') %tr %td.shortcut %kbd [ \/ %kbd k - %td= _('Previous file in diff (MRs only)') + %td= _('Previous file in diff') %tr %td.shortcut - if browser.platform.mac? %kbd ⌘ p - else %kbd ctrl p - %td= _('Go to file (MRs only)') + %td= _('Go to file') %tr %td.shortcut %kbd n - %td= _('Next unresolved discussion (MRs only)') + %td= _('Next unresolved discussion') %tr %td.shortcut %kbd p - %td= _('Previous unresolved discussion (MRs only)') + %td= _('Previous unresolved discussion') %tbody %tr %th - %th= _('Epics (Ultimate / Gold license only)') - %tr - %td.shortcut - %kbd r - %td= _('Comment/Reply (quoting selected text)') - %tr - %td.shortcut - %kbd e - %td= _('Edit epic description') - %tr - %td.shortcut - %kbd l - %td= _('Change label') - %tbody - %tr - %th - %th= _('Repository Graph') - %tr - %td.shortcut - %kbd - %i.fa.fa-arrow-left - \/ - %kbd h - %td= _('Scroll left') - %tr - %td.shortcut - %kbd - %i.fa.fa-arrow-right - \/ - %kbd l - %td= _('Scroll right') - %tr - %td.shortcut - %kbd - %i.fa.fa-arrow-up - \/ - %kbd k - %td= _('Scroll up') - %tr - %td.shortcut - %kbd - %i.fa.fa-arrow-down - \/ - %kbd j - %td= _('Scroll down') + %th= _('Web IDE') %tr %td.shortcut - %kbd - shift - %i.fa.fa-arrow-up - \/ k - %td= _('Scroll to top') + - if browser.platform.mac? + %kbd ⌘ p + - else + %kbd ctrl p + %td= _('Go to file') %tr %td.shortcut - %kbd - shift - %i.fa.fa-arrow-down - \/ j - %td= _('Scroll to bottom') + - if browser.platform.mac? + %kbd ⌘ enter + - else + %kbd ctrl enter + %td= _('Commit (when editing commit message)') diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 06e3bca99a1..80a14412968 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -5,7 +5,8 @@ .mobile-overlay .alert-wrapper = render 'shared/outdated_browser' - = render_if_exists "layouts/header/ee_license_banner" + - if Feature.enabled?(:subscribable_banner_license) + = render_if_exists "layouts/header/ee_subscribable_banner" = render "layouts/broadcast" = render "layouts/header/read_only_banner" = render "layouts/nav/classification_level_banner" diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index c6299f244ec..410b120396d 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -26,7 +26,7 @@ - if current_user_menu?(:settings) %li = link_to s_("CurrentUser|Settings"), profile_path, data: { qa_selector: 'settings_link' } - = render_if_exists 'layouts/header/buy_ci_minutes' + = render_if_exists 'layouts/header/buy_ci_minutes', project: @project, namespace: @group - if current_user_menu?(:help) %li.divider.d-md-none diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index a003d6f8903..2b3f5d266b0 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -1,5 +1,6 @@ %ul - if current_user_menu?(:help) + = render_if_exists 'layouts/header/whats_new_dropdown_item' %li = link_to _("Help"), help_path %li diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 8115c713a4f..f63a7b3a664 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -155,6 +155,11 @@ %span = _('Projects') + = nav_link(controller: :repository) do + = link_to group_settings_repository_path(@group), title: _('Repository') do + %span + = _('Repository') + = nav_link(controller: :ci_cd) do = link_to group_settings_ci_cd_path(@group), title: _('CI / CD') do %span diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index c11d1256d21..3aa056fad7b 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -222,6 +222,12 @@ %span = _('Metrics') + - if project_nav_tab?(:alert_management) + = nav_link(controller: :alert_management) do + = link_to project_alert_management_index_path(@project), title: _('Alerts'), class: 'shortcuts-tracking qa-operations-tracking-link' do + %span + = _('Alerts') + = render_if_exists "layouts/nav/sidebar/tracing_link" = nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do diff --git a/app/views/notify/issues_csv_email.html.haml b/app/views/notify/issues_csv_email.html.haml new file mode 100644 index 00000000000..b777ca1e57d --- /dev/null +++ b/app/views/notify/issues_csv_email.html.haml @@ -0,0 +1,9 @@ +-# haml-lint:disable NoPlainNodes +%p{ style: 'font-size:18px; text-align:center; line-height:30px;' } + Your CSV export of #{ pluralize(@written_count, 'issue') } from project + %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" } + = @project.full_name + has been added to this email as an attachment. + - if @truncated + %p + This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. #{ @written_count } of #{ @issues_count } issues have been included. Consider re-exporting with a narrower selection of issues. diff --git a/app/views/notify/issues_csv_email.text.erb b/app/views/notify/issues_csv_email.text.erb new file mode 100644 index 00000000000..5d4128e3ae9 --- /dev/null +++ b/app/views/notify/issues_csv_email.text.erb @@ -0,0 +1,5 @@ +Your CSV export of <%= pluralize(@written_count, 'issue') %> from project <%= @project.full_name %> (<%= project_url(@project) %>) has been added to this email as an attachment. + +<% if @truncated %> +This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. <%= @written_count %> of <%= @issues_count %> issues have been included. Consider re-exporting with a narrower selection of issues. +<% end %> diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml index f9222387e97..8217608db4e 100644 --- a/app/views/projects/_flash_messages.html.haml +++ b/app/views/projects/_flash_messages.html.haml @@ -8,4 +8,6 @@ - unless project.empty_repo? = render 'shared/auto_devops_implicitly_enabled_banner', project: project = render_if_exists 'projects/above_size_limit_warning', project: project + - if Feature.enabled?(:subscribable_banner_subscription) + = render_if_exists "layouts/header/ee_subscribable_banner", subscription: true = render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)] diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index d9887cb470a..be58ecb3572 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -14,6 +14,7 @@ = @project.name %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) + = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project .home-panel-metadata.d-flex.flex-wrap.text-secondary - if can?(current_user, :read_project, @project) %span.text-secondary diff --git a/app/views/projects/alert_management/index.html.haml b/app/views/projects/alert_management/index.html.haml new file mode 100644 index 00000000000..dab6aec0446 --- /dev/null +++ b/app/views/projects/alert_management/index.html.haml @@ -0,0 +1,3 @@ +- page_title _('Alerts') + +#js-alert_management{ data: alert_management_data(@project) } diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index cae8bbf8c01..445752d0a15 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -12,14 +12,13 @@ %h5.m-0.dropdown-bold-header= _('Download source code') .dropdown-menu-content = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil - - if Feature.enabled?(:git_archive_path, default_enabled: true) - - if vue_file_list_enabled? - #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } - - elsif directory? - %section.border-top.pt-1.mt-1 - %h5.m-0.dropdown-bold-header= _('Download this directory') - .dropdown-menu-content - = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path + - if vue_file_list_enabled? + #js-directory-downloads{ data: { links: directory_download_links(project, ref, archive_prefix).to_json } } + - elsif directory? + %section.border-top.pt-1.mt-1 + %h5.m-0.dropdown-bold-header= _('Download this directory') + .dropdown-menu-content + = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path - if pipeline && pipeline.latest_builds_with_artifacts.any? %section.border-top.pt-1.mt-1 %h5.m-0.dropdown-bold-header= _('Download artifacts') diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index b0d9dfb0d37..da20fee227a 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -10,27 +10,25 @@ .card .card-header {{ __('Recent Project Activity') }} - .content-block - .container-fluid - .row - .col-12.column{ "v-for" => "item in state.summary", ":class" => "summaryTableColumnClass" } - %h3.header {{ item.value }} - %p.text {{ item.title }} - .col-12.column{ ":class" => "summaryTableColumnClass" } - .dropdown.inline.js-ca-dropdown - %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } - %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }} - %i.fa.fa-chevron-down - %ul.dropdown-menu.dropdown-menu-right - %li - %a{ "href" => "#", "data-value" => "7" } - {{ n__('Last %d day', 'Last %d days', 7) }} - %li - %a{ "href" => "#", "data-value" => "30" } - {{ n__('Last %d day', 'Last %d days', 30) }} - %li - %a{ "href" => "#", "data-value" => "90" } - {{ n__('Last %d day', 'Last %d days', 90) }} + .d-flex.justify-content-between + .flex-grow.text-center{ "v-for" => "item in state.summary" } + %h3.header {{ item.value }} + %p.text {{ item.title }} + .flex-grow.align-self-center.text-center + .dropdown.inline.js-ca-dropdown + %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" } + %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }} + %i.fa.fa-chevron-down + %ul.dropdown-menu.dropdown-menu-right + %li + %a{ "href" => "#", "data-value" => "7" } + {{ n__('Last %d day', 'Last %d days', 7) }} + %li + %a{ "href" => "#", "data-value" => "30" } + {{ n__('Last %d day', 'Last %d days', 30) }} + %li + %a{ "href" => "#", "data-value" => "90" } + {{ n__('Last %d day', 'Last %d days', 90) }} .stage-panel-container .card.stage-panel .card-header.border-bottom-0 diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml index 6003f33f0ba..4106bcc2e5a 100644 --- a/app/views/projects/import/jira/show.html.haml +++ b/app/views/projects/import/jira/show.html.haml @@ -1,6 +1,9 @@ -- if Feature.enabled?(:jira_issue_import_vue, @project) +- if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true) .js-jira-import-root{ data: { project_path: @project.full_path, - is_jira_configured: @is_jira_configured.to_s, + issues_path: project_issues_path(@project), + is_jira_configured: @project.jira_service.present?.to_s, + jira_projects: @jira_projects.to_json, + in_progress_illustration: image_path('illustrations/export-import.svg'), setup_illustration: image_path('illustrations/manual_action.svg') } } - else - title = _('Jira Issue Import') diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml index c347b8d2c9c..71c9bb36936 100644 --- a/app/views/projects/issues/_nav_btns.html.haml +++ b/app/views/projects/issues/_nav_btns.html.haml @@ -8,7 +8,7 @@ .btn-group - if show_export_button - = render_if_exists 'projects/issues/export_csv/button' + = render 'projects/issues/export_csv/button' - if show_import_button = render 'projects/issues/import_csv/button' @@ -23,7 +23,7 @@ id: "new_issue_link" - if show_export_button - = render_if_exists 'projects/issues/export_csv/modal' + = render 'projects/issues/export_csv/modal' - if show_import_button = render 'projects/issues/import_csv/modal' diff --git a/app/views/projects/issues/export_csv/_button.html.haml b/app/views/projects/issues/export_csv/_button.html.haml new file mode 100644 index 00000000000..ef3fb438641 --- /dev/null +++ b/app/views/projects/issues/export_csv/_button.html.haml @@ -0,0 +1,4 @@ +- if current_user + %button.csv_download_link.btn.has-tooltip{ title: _('Export as CSV'), + data: { toggle: 'modal', target: '.issues-export-modal', qa_selector: 'export_as_csv_button' } } + = sprite_icon('export') diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml new file mode 100644 index 00000000000..af3a087ca59 --- /dev/null +++ b/app/views/projects/issues/export_csv/_modal.html.haml @@ -0,0 +1,22 @@ +-# haml-lint:disable NoPlainNodes +- if current_user + .issues-export-modal.modal + .modal-dialog + .modal-content{ data: { qa_selector: 'export_issues_modal' } } + .modal-header + %h3 + = _('Export issues') + .svg-content.import-export-svg-container + = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration' + %a.close{ href: '#', 'data-dismiss' => 'modal' } + = sprite_icon('close', size: 16, css_class: 'gl-icon') + .modal-body + .modal-subheader + = icon('check', { class: 'checkmark' }) + %strong.prepend-left-10 + - issues_count = issuables_count_for_state(:issues, params[:state]) + = n_('%d issue selected', '%d issues selected', issues_count) % issues_count + .modal-text + = _('The CSV export will be created in the background. Once finished, it will be sent to <strong>%{email}</strong> in an attachment.').html_safe % { email: @current_user.notification_email } + .modal-footer + = link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn btn-success float-left', title: _('Export issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" } diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 4fc67884584..e8987265a93 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -10,6 +10,8 @@ - can_report_spam = @issue.submittable_as_spam_by?(current_user) - can_create_issue = show_new_issue_link?(@project) += render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user + .detail-page-header .detail-page-header-body .issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(@issue, status_box: :closed) } @@ -50,7 +52,7 @@ %li.divider %li= link_to 'New issue', new_project_issue_path(@project), id: 'new_issue_link' - = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue, can_reopen: can_reopen_issue + = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue, can_reopen: can_reopen_issue, warn_before_close: defined?(@issue.blocked?) && @issue.blocked? - if can_report_spam = link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'd-none d-sm-none d-md-block btn btn-grouped btn-spam', title: 'Submit as spam' diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml index 2eb96a7bc9b..1366c67f84e 100644 --- a/app/views/shared/issuable/_close_reopen_button.html.haml +++ b/app/views/shared/issuable/_close_reopen_button.html.haml @@ -2,17 +2,20 @@ - display_issuable_type = issuable_display_type(issuable) - button_method = issuable_close_reopen_button_method(issuable) - are_close_and_open_buttons_hidden = issuable_button_hidden?(issuable, true) && issuable_button_hidden?(issuable, false) +- add_blocked_class = false +- if defined? warn_before_close + - add_blocked_class = warn_before_close - if is_current_user - if can_update = link_to "Close #{display_issuable_type}", close_issuable_path(issuable), method: button_method, - class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}", data: { qa_selector: 'close_issue_button' } + class: "d-none d-sm-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: "Close #{display_issuable_type}", data: { qa_selector: 'close_issue_button' } - if can_reopen = link_to "Reopen #{display_issuable_type}", reopen_issuable_path(issuable), method: button_method, class: "d-none d-sm-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}", data: { qa_selector: 'reopen_issue_button' } - else - if can_update && !are_close_and_open_buttons_hidden - = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable + = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class - else = link_to 'Report abuse', new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), class: 'd-none d-sm-none d-md-block btn btn-grouped btn-close-color', title: 'Report abuse' diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml index 0d59c9304b4..e71387b50bf 100644 --- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml +++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml @@ -5,10 +5,13 @@ - button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button" - toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle" - button_method = issuable_close_reopen_button_method(issuable) +- add_blocked_class = false +- if defined? warn_before_close + - add_blocked_class = !issuable.closed? && warn_before_close .float-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown = link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_path(issuable), - method: button_method, class: "#{button_class} btn-#{button_action}", title: "#{display_button_action} #{display_issuable_type}" + method: button_method, class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", title: "#{display_button_action} #{display_issuable_type}" = button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color", data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => 'Toggle dropdown' do diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index d29ba3eedc6..3d61943193f 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -54,6 +54,10 @@ .metadata-info.prepend-top-8 %span.user-access-role.d-block= Gitlab::Access.human_access(access) + - if !explore_projects_tab? + .metadata-info.prepend-top-8 + = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project + - if show_last_commit_as_description .description.d-none.d-sm-block.append-right-default = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message") diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml index 24b4eae0c58..675a8f922c4 100644 --- a/app/views/shared/runners/_form.html.haml +++ b/app/views/shared/runners/_form.html.haml @@ -47,5 +47,16 @@ .col-sm-10 = f.text_field :tag_list, value: runner.tag_list.sort.join(', '), class: 'form-control' .form-text.text-muted= _('You can set up jobs to only use Runners with specific tags. Separate tags with commas.') + - if local_assigns[:in_gitlab_com_admin_context] + .form-group.row + = label_tag :public_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do + = _('Public projects Minutes cost factor') + .col-sm-10 + = f.text_field :public_projects_minutes_cost_factor, class: 'form-control' + .form-group.row + = label_tag :private_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do + = _('Private projects Minutes cost factor') + .col-sm-10 + = f.text_field :private_projects_minutes_cost_factor, class: 'form-control' .form-actions = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 5ba6d52fefe..396b6e56ea9 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -1,54 +1,58 @@ -- content_for :page_specific_javascripts do - = page_specific_javascript_tag('lib/ace.js') - -.snippet-form-holder - = form_for @snippet, url: url, - html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" }, - data: { "snippet-type": @snippet.project_id ? 'project' : 'personal'} do |f| - = form_errors(@snippet) - - .form-group - = f.label :title, class: 'label-bold' - = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true - - .form-group.js-description-input - - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...') - - is_expanded = @snippet.description && !@snippet.description.empty? - = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold' - .js-collapsible-input - .js-collapsed{ class: ('d-none' if is_expanded) } - = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' } - .js-expanded{ class: ('d-none' if !is_expanded) } - = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field' - = render 'shared/notes/hints' - - .form-group.file-editor - = f.label :file_name, s_('Snippets|File') - .file-holder.snippet - .js-file-title.file-title-flex-parent - = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name qa-snippet-file-name' - .file-content.code - %pre#editor{ data: { 'editor-loading': true } }= @snippet.content - = f.hidden_field :content, class: 'snippet-file-content' - - .form-group - .font-weight-bold - = _('Visibility level') - = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank' - = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false - - - if params[:files] - - params[:files].each_with_index do |file, index| - = hidden_field_tag "files[]", file, id: "files_#{index}" - - .form-actions - - if @snippet.new_record? - = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button" - - else - = f.submit 'Save changes', class: "btn-success btn" - - - if @snippet.project_id - = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel" - - else - = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel" +- if Feature.disabled?(:monaco_snippets) + - content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/ace.js') + +- if Feature.enabled?(:snippets_edit_vue) + #js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access") } } +- else + .snippet-form-holder + = form_for @snippet, url: url, + html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" }, + data: { "snippet-type": @snippet.project_id ? 'project' : 'personal'} do |f| + = form_errors(@snippet) + + .form-group + = f.label :title, class: 'label-bold' + = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true + + .form-group.js-description-input + - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...') + - is_expanded = @snippet.description && !@snippet.description.empty? + = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold' + .js-collapsible-input + .js-collapsed{ class: ('d-none' if is_expanded) } + = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' } + .js-expanded{ class: ('d-none' if !is_expanded) } + = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do + = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field' + = render 'shared/notes/hints' + + .form-group.file-editor + = f.label :file_name, s_('Snippets|File') + .file-holder.snippet + .js-file-title.file-title-flex-parent + = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name qa-snippet-file-name' + .file-content.code + %pre#editor{ data: { 'editor-loading': true } }= @snippet.content + = f.hidden_field :content, class: 'snippet-file-content' + + .form-group + .font-weight-bold + = _('Visibility level') + = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank' + = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false + + - if params[:files] + - params[:files].each_with_index do |file, index| + = hidden_field_tag "files[]", file, id: "files_#{index}" + + .form-actions + - if @snippet.new_record? + = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button" + - else + = f.submit 'Save changes', class: "btn-success btn" + + - if @snippet.project_id + = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel" + - else + = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel" diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 3fea2c1e3fc..128ddbb8e8b 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,6 +1,5 @@ - link_project = local_assigns.fetch(:link_project, false) - notes_count = @noteable_meta_data[snippet.id].user_notes_count -- file_name = snippet_file_name(snippet) %li.snippet-row.py-3 = image_tag avatar_icon_for_user(snippet.author), class: "avatar s40 d-none d-sm-block", alt: '' @@ -8,10 +7,6 @@ .title = link_to gitlab_snippet_path(snippet) do = snippet.title - - if file_name.present? - %span.snippet-filename.d-none.d-sm-inline-block.ml-2 - = sprite_icon('doc-code', size: 16, css_class: 'file-icon align-text-bottom') - = file_name %ul.controls %li diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 38f518458d6..694f2c23e1e 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -269,6 +269,13 @@ :resource_boundary: :unknown :weight: 1 :idempotent: +- :name: cronjob:x509_issuer_crl_check + :feature_category: :source_code_management + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true - :name: deployment:deployments_finished :feature_category: :continuous_delivery :has_external_dependencies: @@ -709,7 +716,7 @@ :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: true - :name: pipeline_creation:create_pipeline :feature_category: :continuous_integration :has_external_dependencies: @@ -849,7 +856,7 @@ :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: true - :name: pipeline_processing:update_head_pipeline_for_merge_request :feature_category: :continuous_integration :has_external_dependencies: @@ -1046,6 +1053,13 @@ :resource_boundary: :unknown :weight: 1 :idempotent: +- :name: export_csv + :feature_category: :issue_tracking + :has_external_dependencies: + :urgency: :low + :resource_boundary: :cpu + :weight: 1 + :idempotent: - :name: file_hook :feature_category: :integrations :has_external_dependencies: diff --git a/app/workers/create_commit_signature_worker.rb b/app/workers/create_commit_signature_worker.rb index 3da21c56eff..9cbc75f8944 100644 --- a/app/workers/create_commit_signature_worker.rb +++ b/app/workers/create_commit_signature_worker.rb @@ -21,14 +21,19 @@ class CreateCommitSignatureWorker # rubocop:disable Scalability/IdempotentWorker return if commits.empty? - # This calculates and caches the signature in the database - commits.each do |commit| + # Instantiate commits first to lazily load the signatures + commits.map! do |commit| case commit.signature_type when :PGP - Gitlab::Gpg::Commit.new(commit).signature + Gitlab::Gpg::Commit.new(commit) when :X509 - Gitlab::X509::Commit.new(commit).signature + Gitlab::X509::Commit.new(commit) end + end + + # This calculates and caches the signature in the database + commits.each do |commit| + commit&.signature rescue => e Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") # rubocop:disable Gitlab/RailsLogger end diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index 1d2708cdb44..0710ef9298b 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ExpirePipelineCacheWorker # rubocop:disable Scalability/IdempotentWorker +class ExpirePipelineCacheWorker include ApplicationWorker include PipelineQueue @@ -8,6 +8,8 @@ class ExpirePipelineCacheWorker # rubocop:disable Scalability/IdempotentWorker urgency :high worker_resource_boundary :cpu + idempotent! + # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) pipeline = Ci::Pipeline.find_by(id: pipeline_id) diff --git a/app/workers/export_csv_worker.rb b/app/workers/export_csv_worker.rb new file mode 100644 index 00000000000..9e2b3ad9bb4 --- /dev/null +++ b/app/workers/export_csv_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ExportCsvWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + feature_category :issue_tracking + worker_resource_boundary :cpu + + def perform(current_user_id, project_id, params) + @current_user = User.find(current_user_id) + @project = Project.find(project_id) + + params.symbolize_keys! + params[:project_id] = project_id + params.delete(:sort) + + issues = IssuesFinder.new(@current_user, params).execute + + Issues::ExportCsvService.new(issues, @project).email(@current_user) + end +end diff --git a/app/workers/gitlab/jira_import/stage/finish_import_worker.rb b/app/workers/gitlab/jira_import/stage/finish_import_worker.rb index 1d57b77ac7e..3e2cfe56cea 100644 --- a/app/workers/gitlab/jira_import/stage/finish_import_worker.rb +++ b/app/workers/gitlab/jira_import/stage/finish_import_worker.rb @@ -10,7 +10,7 @@ module Gitlab def import(project) JiraImport.cache_cleanup(project.id) - project.latest_jira_import&.finish! + project.latest_jira_import.finish! end end end diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb index c60bee0ffdc..2166655115d 100644 --- a/app/workers/project_daily_statistics_worker.rb +++ b/app/workers/project_daily_statistics_worker.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/214585 class ProjectDailyStatisticsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb index aface8288e3..20db19536c3 100644 --- a/app/workers/stage_update_worker.rb +++ b/app/workers/stage_update_worker.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true -class StageUpdateWorker # rubocop:disable Scalability/IdempotentWorker +class StageUpdateWorker include ApplicationWorker include PipelineQueue queue_namespace :pipeline_processing urgency :high + idempotent! + def perform(stage_id) Ci::Stage.find_by_id(stage_id)&.update_legacy_status end diff --git a/app/workers/x509_issuer_crl_check_worker.rb b/app/workers/x509_issuer_crl_check_worker.rb new file mode 100644 index 00000000000..5fc92da803c --- /dev/null +++ b/app/workers/x509_issuer_crl_check_worker.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class X509IssuerCrlCheckWorker + include ApplicationWorker + include CronjobQueue + + feature_category :source_code_management + urgency :low + + idempotent! + worker_has_external_dependencies! + + attr_accessor :logger + + def perform + @logger = Gitlab::GitLogger.build + + X509Issuer.all.find_each do |issuer| + with_context(related_class: X509IssuerCrlCheckWorker) do + update_certificates(issuer) + end + end + end + + private + + def update_certificates(issuer) + crl = download_crl(issuer) + return unless crl + + serials = X509Certificate.serial_numbers(issuer) + return if serials.empty? + + revoked_serials = serials & crl.revoked.map(&:serial).map(&:to_i) + + revoked_serials.each_slice(1000) do |batch| + certs = issuer.x509_certificates.where(serial_number: batch, certificate_status: :good) # rubocop: disable CodeReuse/ActiveRecord + + certs.find_each do |cert| + logger.info(message: "Certificate revoked", + id: cert.id, + email: cert.email, + subject: cert.subject, + serial_number: cert.serial_number, + issuer: cert.x509_issuer.id, + issuer_subject: cert.x509_issuer.subject, + issuer_crl_url: cert.x509_issuer.crl_url) + end + + certs.update_all(certificate_status: :revoked) + end + end + + def download_crl(issuer) + response = Gitlab::HTTP.try_get(issuer.crl_url) + + if response&.code == 200 + OpenSSL::X509::CRL.new(response.body) + else + logger.warn(message: "Failed to download certificate revocation list", + issuer: issuer.id, + issuer_subject: issuer.subject, + issuer_crl_url: issuer.crl_url) + + nil + end + + rescue OpenSSL::X509::CRLError + logger.warn(message: "Failed to parse certificate revocation list", + issuer: issuer.id, + issuer_subject: issuer.subject, + issuer_crl_url: issuer.crl_url) + + nil + end +end |